feat(hr): Add complete HR frontend module
- API Client: hr.api.ts with employees, departments, contracts, leaves, leaveTypes - Types: hr.types.ts with all interfaces and enums - Hooks: useEmployees, useDepartments, useContracts, useLeaves, useLeaveTypes - Pages: EmployeesPage, EmployeeDetailPage, EmployeeFormPage, DepartmentsPage, ContractsPage, LeavesPage, LeaveRequestPage Implements frontend for MGN-010 HR module Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
14b84c61e2
commit
1363fbd5f6
31
package-lock.json
generated
31
package-lock.json
generated
@ -118,7 +118,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@ -488,7 +487,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@ -512,7 +510,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@ -1582,7 +1579,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@ -1642,7 +1640,6 @@
|
||||
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@ -1660,7 +1657,6 @@
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@ -1672,7 +1668,6 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@ -1712,7 +1707,6 @@
|
||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
@ -2058,7 +2052,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -2476,7 +2469,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@ -3020,7 +3012,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
@ -3305,7 +3298,6 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@ -4760,7 +4752,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@ -4790,7 +4781,6 @@
|
||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.1.0",
|
||||
"data-urls": "^5.0.0",
|
||||
@ -5000,6 +4990,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@ -5544,7 +5535,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@ -5725,6 +5715,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@ -5740,6 +5731,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@ -5808,7 +5800,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@ -5821,7 +5812,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@ -5835,7 +5825,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz",
|
||||
"integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@ -5852,7 +5841,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
@ -6743,7 +6733,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -6976,7 +6965,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -7065,7 +7053,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
504
src/features/hr/api/hr.api.ts
Normal file
504
src/features/hr/api/hr.api.ts
Normal file
@ -0,0 +1,504 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type {
|
||||
Employee,
|
||||
CreateEmployeeDto,
|
||||
UpdateEmployeeDto,
|
||||
EmployeeFilters,
|
||||
EmployeesResponse,
|
||||
Department,
|
||||
CreateDepartmentDto,
|
||||
UpdateDepartmentDto,
|
||||
DepartmentFilters,
|
||||
DepartmentsResponse,
|
||||
JobPosition,
|
||||
CreateJobPositionDto,
|
||||
UpdateJobPositionDto,
|
||||
JobPositionsResponse,
|
||||
Contract,
|
||||
CreateContractDto,
|
||||
UpdateContractDto,
|
||||
ContractFilters,
|
||||
ContractsResponse,
|
||||
Leave,
|
||||
CreateLeaveDto,
|
||||
UpdateLeaveDto,
|
||||
LeaveFilters,
|
||||
LeavesResponse,
|
||||
LeaveType,
|
||||
CreateLeaveTypeDto,
|
||||
UpdateLeaveTypeDto,
|
||||
LeaveTypesResponse,
|
||||
} from '../types';
|
||||
|
||||
const HR_BASE = '/api/v1/hr';
|
||||
|
||||
// ============================================================================
|
||||
// Employees API
|
||||
// ============================================================================
|
||||
|
||||
export const employeesApi = {
|
||||
getAll: async (filters: EmployeeFilters = {}): Promise<EmployeesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.companyId) params.append('company_id', filters.companyId);
|
||||
if (filters.departmentId) params.append('department_id', filters.departmentId);
|
||||
if (filters.status) params.append('status', filters.status);
|
||||
if (filters.managerId) params.append('manager_id', filters.managerId);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.page) params.append('page', String(filters.page));
|
||||
if (filters.limit) params.append('limit', String(filters.limit));
|
||||
|
||||
const response = await api.get<{ success: boolean; data: Employee[]; meta: { total: number; page: number; limit: number; totalPages: number } }>(
|
||||
`${HR_BASE}/employees?${params}`
|
||||
);
|
||||
return {
|
||||
data: response.data.data,
|
||||
total: response.data.meta.total,
|
||||
page: response.data.meta.page,
|
||||
limit: response.data.meta.limit,
|
||||
totalPages: response.data.meta.totalPages,
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Employee> => {
|
||||
const response = await api.get<{ success: boolean; data: Employee }>(`${HR_BASE}/employees/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateEmployeeDto): Promise<Employee> => {
|
||||
const payload = {
|
||||
company_id: data.companyId,
|
||||
employee_number: data.employeeNumber,
|
||||
first_name: data.firstName,
|
||||
last_name: data.lastName,
|
||||
middle_name: data.middleName,
|
||||
user_id: data.userId,
|
||||
birth_date: data.birthDate,
|
||||
gender: data.gender,
|
||||
marital_status: data.maritalStatus,
|
||||
nationality: data.nationality,
|
||||
identification_id: data.identificationId,
|
||||
identification_type: data.identificationType,
|
||||
social_security_number: data.socialSecurityNumber,
|
||||
tax_id: data.taxId,
|
||||
email: data.email,
|
||||
work_email: data.workEmail,
|
||||
phone: data.phone,
|
||||
work_phone: data.workPhone,
|
||||
mobile: data.mobile,
|
||||
emergency_contact: data.emergencyContact,
|
||||
emergency_phone: data.emergencyPhone,
|
||||
street: data.street,
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
zip: data.zip,
|
||||
country: data.country,
|
||||
department_id: data.departmentId,
|
||||
job_position_id: data.jobPositionId,
|
||||
manager_id: data.managerId,
|
||||
hire_date: data.hireDate,
|
||||
bank_name: data.bankName,
|
||||
bank_account: data.bankAccount,
|
||||
bank_clabe: data.bankClabe,
|
||||
photo_url: data.photoUrl,
|
||||
notes: data.notes,
|
||||
};
|
||||
const response = await api.post<{ success: boolean; data: Employee }>(`${HR_BASE}/employees`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateEmployeeDto): Promise<Employee> => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.firstName !== undefined) payload.first_name = data.firstName;
|
||||
if (data.lastName !== undefined) payload.last_name = data.lastName;
|
||||
if (data.middleName !== undefined) payload.middle_name = data.middleName;
|
||||
if (data.userId !== undefined) payload.user_id = data.userId;
|
||||
if (data.birthDate !== undefined) payload.birth_date = data.birthDate;
|
||||
if (data.gender !== undefined) payload.gender = data.gender;
|
||||
if (data.maritalStatus !== undefined) payload.marital_status = data.maritalStatus;
|
||||
if (data.nationality !== undefined) payload.nationality = data.nationality;
|
||||
if (data.identificationId !== undefined) payload.identification_id = data.identificationId;
|
||||
if (data.identificationType !== undefined) payload.identification_type = data.identificationType;
|
||||
if (data.socialSecurityNumber !== undefined) payload.social_security_number = data.socialSecurityNumber;
|
||||
if (data.taxId !== undefined) payload.tax_id = data.taxId;
|
||||
if (data.email !== undefined) payload.email = data.email;
|
||||
if (data.workEmail !== undefined) payload.work_email = data.workEmail;
|
||||
if (data.phone !== undefined) payload.phone = data.phone;
|
||||
if (data.workPhone !== undefined) payload.work_phone = data.workPhone;
|
||||
if (data.mobile !== undefined) payload.mobile = data.mobile;
|
||||
if (data.emergencyContact !== undefined) payload.emergency_contact = data.emergencyContact;
|
||||
if (data.emergencyPhone !== undefined) payload.emergency_phone = data.emergencyPhone;
|
||||
if (data.street !== undefined) payload.street = data.street;
|
||||
if (data.city !== undefined) payload.city = data.city;
|
||||
if (data.state !== undefined) payload.state = data.state;
|
||||
if (data.zip !== undefined) payload.zip = data.zip;
|
||||
if (data.country !== undefined) payload.country = data.country;
|
||||
if (data.departmentId !== undefined) payload.department_id = data.departmentId;
|
||||
if (data.jobPositionId !== undefined) payload.job_position_id = data.jobPositionId;
|
||||
if (data.managerId !== undefined) payload.manager_id = data.managerId;
|
||||
if (data.bankName !== undefined) payload.bank_name = data.bankName;
|
||||
if (data.bankAccount !== undefined) payload.bank_account = data.bankAccount;
|
||||
if (data.bankClabe !== undefined) payload.bank_clabe = data.bankClabe;
|
||||
if (data.photoUrl !== undefined) payload.photo_url = data.photoUrl;
|
||||
if (data.notes !== undefined) payload.notes = data.notes;
|
||||
|
||||
const response = await api.put<{ success: boolean; data: Employee }>(`${HR_BASE}/employees/${id}`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${HR_BASE}/employees/${id}`);
|
||||
},
|
||||
|
||||
terminate: async (id: string, terminationDate: string): Promise<Employee> => {
|
||||
const response = await api.post<{ success: boolean; data: Employee }>(`${HR_BASE}/employees/${id}/terminate`, {
|
||||
termination_date: terminationDate,
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
reactivate: async (id: string): Promise<Employee> => {
|
||||
const response = await api.post<{ success: boolean; data: Employee }>(`${HR_BASE}/employees/${id}/reactivate`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getSubordinates: async (id: string): Promise<Employee[]> => {
|
||||
const response = await api.get<{ success: boolean; data: Employee[] }>(`${HR_BASE}/employees/${id}/subordinates`);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Departments API
|
||||
// ============================================================================
|
||||
|
||||
export const departmentsApi = {
|
||||
getAll: async (filters: DepartmentFilters = {}): Promise<DepartmentsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.companyId) params.append('company_id', filters.companyId);
|
||||
if (filters.isActive !== undefined) params.append('active', String(filters.isActive));
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.page) params.append('page', String(filters.page));
|
||||
if (filters.limit) params.append('limit', String(filters.limit));
|
||||
|
||||
const response = await api.get<{ success: boolean; data: Department[]; meta: { total: number; page: number; limit: number; totalPages: number } }>(
|
||||
`${HR_BASE}/departments?${params}`
|
||||
);
|
||||
return {
|
||||
data: response.data.data,
|
||||
total: response.data.meta.total,
|
||||
page: response.data.meta.page,
|
||||
limit: response.data.meta.limit,
|
||||
totalPages: response.data.meta.totalPages,
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Department> => {
|
||||
const response = await api.get<{ success: boolean; data: Department }>(`${HR_BASE}/departments/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateDepartmentDto): Promise<Department> => {
|
||||
const payload = {
|
||||
company_id: data.companyId,
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
parent_id: data.parentId,
|
||||
manager_id: data.managerId,
|
||||
description: data.description,
|
||||
color: data.color,
|
||||
};
|
||||
const response = await api.post<{ success: boolean; data: Department }>(`${HR_BASE}/departments`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateDepartmentDto): Promise<Department> => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) payload.name = data.name;
|
||||
if (data.code !== undefined) payload.code = data.code;
|
||||
if (data.parentId !== undefined) payload.parent_id = data.parentId;
|
||||
if (data.managerId !== undefined) payload.manager_id = data.managerId;
|
||||
if (data.description !== undefined) payload.description = data.description;
|
||||
if (data.color !== undefined) payload.color = data.color;
|
||||
if (data.isActive !== undefined) payload.active = data.isActive;
|
||||
|
||||
const response = await api.put<{ success: boolean; data: Department }>(`${HR_BASE}/departments/${id}`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${HR_BASE}/departments/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Job Positions API
|
||||
// ============================================================================
|
||||
|
||||
export const jobPositionsApi = {
|
||||
getAll: async (includeInactive = false): Promise<JobPositionsResponse> => {
|
||||
const params = includeInactive ? '?include_inactive=true' : '';
|
||||
const response = await api.get<{ success: boolean; data: JobPosition[] }>(`${HR_BASE}/positions${params}`);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
|
||||
create: async (data: CreateJobPositionDto): Promise<JobPosition> => {
|
||||
const payload = {
|
||||
name: data.name,
|
||||
department_id: data.departmentId,
|
||||
description: data.description,
|
||||
requirements: data.requirements,
|
||||
responsibilities: data.responsibilities,
|
||||
min_salary: data.minSalary,
|
||||
max_salary: data.maxSalary,
|
||||
};
|
||||
const response = await api.post<{ success: boolean; data: JobPosition }>(`${HR_BASE}/positions`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateJobPositionDto): Promise<JobPosition> => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) payload.name = data.name;
|
||||
if (data.departmentId !== undefined) payload.department_id = data.departmentId;
|
||||
if (data.description !== undefined) payload.description = data.description;
|
||||
if (data.requirements !== undefined) payload.requirements = data.requirements;
|
||||
if (data.responsibilities !== undefined) payload.responsibilities = data.responsibilities;
|
||||
if (data.minSalary !== undefined) payload.min_salary = data.minSalary;
|
||||
if (data.maxSalary !== undefined) payload.max_salary = data.maxSalary;
|
||||
if (data.isActive !== undefined) payload.active = data.isActive;
|
||||
|
||||
const response = await api.put<{ success: boolean; data: JobPosition }>(`${HR_BASE}/positions/${id}`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${HR_BASE}/positions/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Contracts API
|
||||
// ============================================================================
|
||||
|
||||
export const contractsApi = {
|
||||
getAll: async (filters: ContractFilters = {}): Promise<ContractsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.companyId) params.append('company_id', filters.companyId);
|
||||
if (filters.employeeId) params.append('employee_id', filters.employeeId);
|
||||
if (filters.status) params.append('status', filters.status);
|
||||
if (filters.contractType) params.append('contract_type', filters.contractType);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.page) params.append('page', String(filters.page));
|
||||
if (filters.limit) params.append('limit', String(filters.limit));
|
||||
|
||||
const response = await api.get<{ success: boolean; data: Contract[]; meta: { total: number; page: number; limit: number; totalPages: number } }>(
|
||||
`${HR_BASE}/contracts?${params}`
|
||||
);
|
||||
return {
|
||||
data: response.data.data,
|
||||
total: response.data.meta.total,
|
||||
page: response.data.meta.page,
|
||||
limit: response.data.meta.limit,
|
||||
totalPages: response.data.meta.totalPages,
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Contract> => {
|
||||
const response = await api.get<{ success: boolean; data: Contract }>(`${HR_BASE}/contracts/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateContractDto): Promise<Contract> => {
|
||||
const payload = {
|
||||
company_id: data.companyId,
|
||||
employee_id: data.employeeId,
|
||||
name: data.name,
|
||||
reference: data.reference,
|
||||
contract_type: data.contractType,
|
||||
job_position_id: data.jobPositionId,
|
||||
department_id: data.departmentId,
|
||||
date_start: data.dateStart,
|
||||
date_end: data.dateEnd,
|
||||
trial_date_end: data.trialDateEnd,
|
||||
wage: data.wage,
|
||||
wage_type: data.wageType,
|
||||
currency_id: data.currencyId,
|
||||
hours_per_week: data.hoursPerWeek,
|
||||
vacation_days: data.vacationDays,
|
||||
christmas_bonus_days: data.christmasBonusDays,
|
||||
document_url: data.documentUrl,
|
||||
notes: data.notes,
|
||||
};
|
||||
const response = await api.post<{ success: boolean; data: Contract }>(`${HR_BASE}/contracts`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateContractDto): Promise<Contract> => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.reference !== undefined) payload.reference = data.reference;
|
||||
if (data.jobPositionId !== undefined) payload.job_position_id = data.jobPositionId;
|
||||
if (data.departmentId !== undefined) payload.department_id = data.departmentId;
|
||||
if (data.dateEnd !== undefined) payload.date_end = data.dateEnd;
|
||||
if (data.trialDateEnd !== undefined) payload.trial_date_end = data.trialDateEnd;
|
||||
if (data.wage !== undefined) payload.wage = data.wage;
|
||||
if (data.wageType !== undefined) payload.wage_type = data.wageType;
|
||||
if (data.currencyId !== undefined) payload.currency_id = data.currencyId;
|
||||
if (data.hoursPerWeek !== undefined) payload.hours_per_week = data.hoursPerWeek;
|
||||
if (data.vacationDays !== undefined) payload.vacation_days = data.vacationDays;
|
||||
if (data.christmasBonusDays !== undefined) payload.christmas_bonus_days = data.christmasBonusDays;
|
||||
if (data.documentUrl !== undefined) payload.document_url = data.documentUrl;
|
||||
if (data.notes !== undefined) payload.notes = data.notes;
|
||||
|
||||
const response = await api.put<{ success: boolean; data: Contract }>(`${HR_BASE}/contracts/${id}`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${HR_BASE}/contracts/${id}`);
|
||||
},
|
||||
|
||||
activate: async (id: string): Promise<Contract> => {
|
||||
const response = await api.post<{ success: boolean; data: Contract }>(`${HR_BASE}/contracts/${id}/activate`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
terminate: async (id: string, terminationDate: string): Promise<Contract> => {
|
||||
const response = await api.post<{ success: boolean; data: Contract }>(`${HR_BASE}/contracts/${id}/terminate`, {
|
||||
termination_date: terminationDate,
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
cancel: async (id: string): Promise<Contract> => {
|
||||
const response = await api.post<{ success: boolean; data: Contract }>(`${HR_BASE}/contracts/${id}/cancel`);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Leave Types API
|
||||
// ============================================================================
|
||||
|
||||
export const leaveTypesApi = {
|
||||
getAll: async (includeInactive = false): Promise<LeaveTypesResponse> => {
|
||||
const params = includeInactive ? '?include_inactive=true' : '';
|
||||
const response = await api.get<{ success: boolean; data: LeaveType[] }>(`${HR_BASE}/leave-types${params}`);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
|
||||
create: async (data: CreateLeaveTypeDto): Promise<LeaveType> => {
|
||||
const payload = {
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
leave_type: data.leaveType,
|
||||
requires_approval: data.requiresApproval,
|
||||
max_days: data.maxDays,
|
||||
is_paid: data.isPaid,
|
||||
color: data.color,
|
||||
};
|
||||
const response = await api.post<{ success: boolean; data: LeaveType }>(`${HR_BASE}/leave-types`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateLeaveTypeDto): Promise<LeaveType> => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) payload.name = data.name;
|
||||
if (data.code !== undefined) payload.code = data.code;
|
||||
if (data.requiresApproval !== undefined) payload.requires_approval = data.requiresApproval;
|
||||
if (data.maxDays !== undefined) payload.max_days = data.maxDays;
|
||||
if (data.isPaid !== undefined) payload.is_paid = data.isPaid;
|
||||
if (data.color !== undefined) payload.color = data.color;
|
||||
if (data.isActive !== undefined) payload.active = data.isActive;
|
||||
|
||||
const response = await api.put<{ success: boolean; data: LeaveType }>(`${HR_BASE}/leave-types/${id}`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${HR_BASE}/leave-types/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Leaves API
|
||||
// ============================================================================
|
||||
|
||||
export const leavesApi = {
|
||||
getAll: async (filters: LeaveFilters = {}): Promise<LeavesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.companyId) params.append('company_id', filters.companyId);
|
||||
if (filters.employeeId) params.append('employee_id', filters.employeeId);
|
||||
if (filters.leaveTypeId) params.append('leave_type_id', filters.leaveTypeId);
|
||||
if (filters.status) params.append('status', filters.status);
|
||||
if (filters.dateFrom) params.append('date_from', filters.dateFrom);
|
||||
if (filters.dateTo) params.append('date_to', filters.dateTo);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.page) params.append('page', String(filters.page));
|
||||
if (filters.limit) params.append('limit', String(filters.limit));
|
||||
|
||||
const response = await api.get<{ success: boolean; data: Leave[]; meta: { total: number; page: number; limit: number; totalPages: number } }>(
|
||||
`${HR_BASE}/leaves?${params}`
|
||||
);
|
||||
return {
|
||||
data: response.data.data,
|
||||
total: response.data.meta.total,
|
||||
page: response.data.meta.page,
|
||||
limit: response.data.meta.limit,
|
||||
totalPages: response.data.meta.totalPages,
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Leave> => {
|
||||
const response = await api.get<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateLeaveDto): Promise<Leave> => {
|
||||
const payload = {
|
||||
company_id: data.companyId,
|
||||
employee_id: data.employeeId,
|
||||
leave_type_id: data.leaveTypeId,
|
||||
name: data.name,
|
||||
date_from: data.dateFrom,
|
||||
date_to: data.dateTo,
|
||||
description: data.description,
|
||||
};
|
||||
const response = await api.post<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateLeaveDto): Promise<Leave> => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.leaveTypeId !== undefined) payload.leave_type_id = data.leaveTypeId;
|
||||
if (data.name !== undefined) payload.name = data.name;
|
||||
if (data.dateFrom !== undefined) payload.date_from = data.dateFrom;
|
||||
if (data.dateTo !== undefined) payload.date_to = data.dateTo;
|
||||
if (data.description !== undefined) payload.description = data.description;
|
||||
|
||||
const response = await api.put<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves/${id}`, payload);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${HR_BASE}/leaves/${id}`);
|
||||
},
|
||||
|
||||
submit: async (id: string): Promise<Leave> => {
|
||||
const response = await api.post<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves/${id}/submit`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
approve: async (id: string): Promise<Leave> => {
|
||||
const response = await api.post<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves/${id}/approve`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
reject: async (id: string, reason: string): Promise<Leave> => {
|
||||
const response = await api.post<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves/${id}/reject`, { reason });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
cancel: async (id: string): Promise<Leave> => {
|
||||
const response = await api.post<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves/${id}/cancel`);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
1
src/features/hr/api/index.ts
Normal file
1
src/features/hr/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './hr.api';
|
||||
6
src/features/hr/hooks/index.ts
Normal file
6
src/features/hr/hooks/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { useEmployees } from './useEmployees';
|
||||
export { useEmployee } from './useEmployee';
|
||||
export { useDepartments, useJobPositions } from './useDepartments';
|
||||
export { useContracts } from './useContracts';
|
||||
export { useLeaves } from './useLeaves';
|
||||
export { useLeaveTypes } from './useLeaveTypes';
|
||||
124
src/features/hr/hooks/useContracts.ts
Normal file
124
src/features/hr/hooks/useContracts.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { contractsApi } from '../api';
|
||||
import type {
|
||||
Contract,
|
||||
CreateContractDto,
|
||||
UpdateContractDto,
|
||||
ContractFilters,
|
||||
} from '../types';
|
||||
|
||||
interface UseContractsOptions {
|
||||
initialFilters?: ContractFilters;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseContractsReturn {
|
||||
contracts: Contract[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
filters: ContractFilters;
|
||||
setFilters: (filters: ContractFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
getById: (id: string) => Promise<Contract>;
|
||||
create: (data: CreateContractDto) => Promise<Contract>;
|
||||
update: (id: string, data: UpdateContractDto) => Promise<Contract>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
activate: (id: string) => Promise<Contract>;
|
||||
terminate: (id: string, terminationDate: string) => Promise<Contract>;
|
||||
cancel: (id: string) => Promise<Contract>;
|
||||
}
|
||||
|
||||
export function useContracts(options: UseContractsOptions = {}): UseContractsReturn {
|
||||
const { initialFilters = {}, autoLoad = true } = options;
|
||||
|
||||
const [contracts, setContracts] = useState<Contract[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<ContractFilters>(initialFilters);
|
||||
|
||||
const fetchContracts = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await contractsApi.getAll(filters);
|
||||
setContracts(response.data);
|
||||
setTotal(response.total);
|
||||
setPage(response.page);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error al cargar contratos'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchContracts();
|
||||
}
|
||||
}, [autoLoad, fetchContracts]);
|
||||
|
||||
const getById = useCallback(async (id: string): Promise<Contract> => {
|
||||
return contractsApi.getById(id);
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (data: CreateContractDto): Promise<Contract> => {
|
||||
const result = await contractsApi.create(data);
|
||||
await fetchContracts();
|
||||
return result;
|
||||
}, [fetchContracts]);
|
||||
|
||||
const update = useCallback(async (id: string, data: UpdateContractDto): Promise<Contract> => {
|
||||
const result = await contractsApi.update(id, data);
|
||||
await fetchContracts();
|
||||
return result;
|
||||
}, [fetchContracts]);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await contractsApi.delete(id);
|
||||
await fetchContracts();
|
||||
}, [fetchContracts]);
|
||||
|
||||
const activate = useCallback(async (id: string): Promise<Contract> => {
|
||||
const result = await contractsApi.activate(id);
|
||||
await fetchContracts();
|
||||
return result;
|
||||
}, [fetchContracts]);
|
||||
|
||||
const terminate = useCallback(async (id: string, terminationDate: string): Promise<Contract> => {
|
||||
const result = await contractsApi.terminate(id, terminationDate);
|
||||
await fetchContracts();
|
||||
return result;
|
||||
}, [fetchContracts]);
|
||||
|
||||
const cancel = useCallback(async (id: string): Promise<Contract> => {
|
||||
const result = await contractsApi.cancel(id);
|
||||
await fetchContracts();
|
||||
return result;
|
||||
}, [fetchContracts]);
|
||||
|
||||
return {
|
||||
contracts,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchContracts,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
activate,
|
||||
terminate,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
180
src/features/hr/hooks/useDepartments.ts
Normal file
180
src/features/hr/hooks/useDepartments.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { departmentsApi, jobPositionsApi } from '../api';
|
||||
import type {
|
||||
Department,
|
||||
CreateDepartmentDto,
|
||||
UpdateDepartmentDto,
|
||||
DepartmentFilters,
|
||||
JobPosition,
|
||||
CreateJobPositionDto,
|
||||
UpdateJobPositionDto,
|
||||
} from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// Departments Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseDepartmentsOptions {
|
||||
initialFilters?: DepartmentFilters;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseDepartmentsReturn {
|
||||
departments: Department[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
filters: DepartmentFilters;
|
||||
setFilters: (filters: DepartmentFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
getById: (id: string) => Promise<Department>;
|
||||
create: (data: CreateDepartmentDto) => Promise<Department>;
|
||||
update: (id: string, data: UpdateDepartmentDto) => Promise<Department>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDepartments(options: UseDepartmentsOptions = {}): UseDepartmentsReturn {
|
||||
const { initialFilters = {}, autoLoad = true } = options;
|
||||
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<DepartmentFilters>(initialFilters);
|
||||
|
||||
const fetchDepartments = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await departmentsApi.getAll(filters);
|
||||
setDepartments(response.data);
|
||||
setTotal(response.total);
|
||||
setPage(response.page);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error al cargar departamentos'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchDepartments();
|
||||
}
|
||||
}, [autoLoad, fetchDepartments]);
|
||||
|
||||
const getById = useCallback(async (id: string): Promise<Department> => {
|
||||
return departmentsApi.getById(id);
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (data: CreateDepartmentDto): Promise<Department> => {
|
||||
const result = await departmentsApi.create(data);
|
||||
await fetchDepartments();
|
||||
return result;
|
||||
}, [fetchDepartments]);
|
||||
|
||||
const update = useCallback(async (id: string, data: UpdateDepartmentDto): Promise<Department> => {
|
||||
const result = await departmentsApi.update(id, data);
|
||||
await fetchDepartments();
|
||||
return result;
|
||||
}, [fetchDepartments]);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await departmentsApi.delete(id);
|
||||
await fetchDepartments();
|
||||
}, [fetchDepartments]);
|
||||
|
||||
return {
|
||||
departments,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchDepartments,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Job Positions Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseJobPositionsOptions {
|
||||
includeInactive?: boolean;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseJobPositionsReturn {
|
||||
positions: JobPosition[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
create: (data: CreateJobPositionDto) => Promise<JobPosition>;
|
||||
update: (id: string, data: UpdateJobPositionDto) => Promise<JobPosition>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useJobPositions(options: UseJobPositionsOptions = {}): UseJobPositionsReturn {
|
||||
const { includeInactive = false, autoLoad = true } = options;
|
||||
|
||||
const [positions, setPositions] = useState<JobPosition[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchPositions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await jobPositionsApi.getAll(includeInactive);
|
||||
setPositions(response.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error al cargar puestos'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [includeInactive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchPositions();
|
||||
}
|
||||
}, [autoLoad, fetchPositions]);
|
||||
|
||||
const create = useCallback(async (data: CreateJobPositionDto): Promise<JobPosition> => {
|
||||
const result = await jobPositionsApi.create(data);
|
||||
await fetchPositions();
|
||||
return result;
|
||||
}, [fetchPositions]);
|
||||
|
||||
const update = useCallback(async (id: string, data: UpdateJobPositionDto): Promise<JobPosition> => {
|
||||
const result = await jobPositionsApi.update(id, data);
|
||||
await fetchPositions();
|
||||
return result;
|
||||
}, [fetchPositions]);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await jobPositionsApi.delete(id);
|
||||
await fetchPositions();
|
||||
}, [fetchPositions]);
|
||||
|
||||
return {
|
||||
positions,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchPositions,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
179
src/features/hr/hooks/useEmployee.ts
Normal file
179
src/features/hr/hooks/useEmployee.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { employeesApi } from '../api';
|
||||
import type {
|
||||
Employee,
|
||||
CreateEmployeeDto,
|
||||
UpdateEmployeeDto,
|
||||
} from '../types';
|
||||
|
||||
interface UseEmployeeOptions {
|
||||
employeeId?: string;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseEmployeeReturn {
|
||||
employee: Employee | null;
|
||||
subordinates: Employee[];
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
loadSubordinates: () => Promise<void>;
|
||||
create: (data: CreateEmployeeDto) => Promise<Employee>;
|
||||
update: (data: UpdateEmployeeDto) => Promise<Employee>;
|
||||
remove: () => Promise<void>;
|
||||
terminate: (terminationDate: string) => Promise<Employee>;
|
||||
reactivate: () => Promise<Employee>;
|
||||
}
|
||||
|
||||
export function useEmployee(options: UseEmployeeOptions = {}): UseEmployeeReturn {
|
||||
const { employeeId, autoLoad = true } = options;
|
||||
|
||||
const [employee, setEmployee] = useState<Employee | null>(null);
|
||||
const [subordinates, setSubordinates] = useState<Employee[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchEmployee = useCallback(async () => {
|
||||
if (!employeeId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await employeesApi.getById(employeeId);
|
||||
setEmployee(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error al cargar empleado'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [employeeId]);
|
||||
|
||||
const loadSubordinates = useCallback(async () => {
|
||||
if (!employeeId) return;
|
||||
|
||||
try {
|
||||
const data = await employeesApi.getSubordinates(employeeId);
|
||||
setSubordinates(data);
|
||||
} catch (err) {
|
||||
console.error('Error loading subordinates:', err);
|
||||
}
|
||||
}, [employeeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad && employeeId) {
|
||||
fetchEmployee();
|
||||
}
|
||||
}, [autoLoad, fetchEmployee, employeeId]);
|
||||
|
||||
const create = useCallback(async (data: CreateEmployeeDto): Promise<Employee> => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await employeesApi.create(data);
|
||||
setEmployee(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error al crear empleado');
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async (data: UpdateEmployeeDto): Promise<Employee> => {
|
||||
if (!employeeId) {
|
||||
throw new Error('Employee ID is required for update');
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await employeesApi.update(employeeId, data);
|
||||
setEmployee(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error al actualizar empleado');
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [employeeId]);
|
||||
|
||||
const remove = useCallback(async (): Promise<void> => {
|
||||
if (!employeeId) {
|
||||
throw new Error('Employee ID is required for delete');
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await employeesApi.delete(employeeId);
|
||||
setEmployee(null);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error al eliminar empleado');
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [employeeId]);
|
||||
|
||||
const terminate = useCallback(async (terminationDate: string): Promise<Employee> => {
|
||||
if (!employeeId) {
|
||||
throw new Error('Employee ID is required for terminate');
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await employeesApi.terminate(employeeId, terminationDate);
|
||||
setEmployee(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error al dar de baja empleado');
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [employeeId]);
|
||||
|
||||
const reactivate = useCallback(async (): Promise<Employee> => {
|
||||
if (!employeeId) {
|
||||
throw new Error('Employee ID is required for reactivate');
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await employeesApi.reactivate(employeeId);
|
||||
setEmployee(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error al reactivar empleado');
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [employeeId]);
|
||||
|
||||
return {
|
||||
employee,
|
||||
subordinates,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
refresh: fetchEmployee,
|
||||
loadSubordinates,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
terminate,
|
||||
reactivate,
|
||||
};
|
||||
}
|
||||
122
src/features/hr/hooks/useEmployees.ts
Normal file
122
src/features/hr/hooks/useEmployees.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { employeesApi } from '../api';
|
||||
import type {
|
||||
Employee,
|
||||
CreateEmployeeDto,
|
||||
UpdateEmployeeDto,
|
||||
EmployeeFilters,
|
||||
} from '../types';
|
||||
|
||||
interface UseEmployeesOptions {
|
||||
initialFilters?: EmployeeFilters;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseEmployeesReturn {
|
||||
employees: Employee[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
filters: EmployeeFilters;
|
||||
setFilters: (filters: EmployeeFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
getById: (id: string) => Promise<Employee>;
|
||||
create: (data: CreateEmployeeDto) => Promise<Employee>;
|
||||
update: (id: string, data: UpdateEmployeeDto) => Promise<Employee>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
terminate: (id: string, terminationDate: string) => Promise<Employee>;
|
||||
reactivate: (id: string) => Promise<Employee>;
|
||||
getSubordinates: (id: string) => Promise<Employee[]>;
|
||||
}
|
||||
|
||||
export function useEmployees(options: UseEmployeesOptions = {}): UseEmployeesReturn {
|
||||
const { initialFilters = {}, autoLoad = true } = options;
|
||||
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<EmployeeFilters>(initialFilters);
|
||||
|
||||
const fetchEmployees = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await employeesApi.getAll(filters);
|
||||
setEmployees(response.data);
|
||||
setTotal(response.total);
|
||||
setPage(response.page);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error al cargar empleados'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchEmployees();
|
||||
}
|
||||
}, [autoLoad, fetchEmployees]);
|
||||
|
||||
const getById = useCallback(async (id: string): Promise<Employee> => {
|
||||
return employeesApi.getById(id);
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (data: CreateEmployeeDto): Promise<Employee> => {
|
||||
const result = await employeesApi.create(data);
|
||||
await fetchEmployees();
|
||||
return result;
|
||||
}, [fetchEmployees]);
|
||||
|
||||
const update = useCallback(async (id: string, data: UpdateEmployeeDto): Promise<Employee> => {
|
||||
const result = await employeesApi.update(id, data);
|
||||
await fetchEmployees();
|
||||
return result;
|
||||
}, [fetchEmployees]);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await employeesApi.delete(id);
|
||||
await fetchEmployees();
|
||||
}, [fetchEmployees]);
|
||||
|
||||
const terminate = useCallback(async (id: string, terminationDate: string): Promise<Employee> => {
|
||||
const result = await employeesApi.terminate(id, terminationDate);
|
||||
await fetchEmployees();
|
||||
return result;
|
||||
}, [fetchEmployees]);
|
||||
|
||||
const reactivate = useCallback(async (id: string): Promise<Employee> => {
|
||||
const result = await employeesApi.reactivate(id);
|
||||
await fetchEmployees();
|
||||
return result;
|
||||
}, [fetchEmployees]);
|
||||
|
||||
const getSubordinates = useCallback(async (id: string): Promise<Employee[]> => {
|
||||
return employeesApi.getSubordinates(id);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
employees,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchEmployees,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
terminate,
|
||||
reactivate,
|
||||
getSubordinates,
|
||||
};
|
||||
}
|
||||
76
src/features/hr/hooks/useLeaveTypes.ts
Normal file
76
src/features/hr/hooks/useLeaveTypes.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { leaveTypesApi } from '../api';
|
||||
import type {
|
||||
LeaveType,
|
||||
CreateLeaveTypeDto,
|
||||
UpdateLeaveTypeDto,
|
||||
} from '../types';
|
||||
|
||||
interface UseLeaveTypesOptions {
|
||||
includeInactive?: boolean;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseLeaveTypesReturn {
|
||||
leaveTypes: LeaveType[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
create: (data: CreateLeaveTypeDto) => Promise<LeaveType>;
|
||||
update: (id: string, data: UpdateLeaveTypeDto) => Promise<LeaveType>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useLeaveTypes(options: UseLeaveTypesOptions = {}): UseLeaveTypesReturn {
|
||||
const { includeInactive = false, autoLoad = true } = options;
|
||||
|
||||
const [leaveTypes, setLeaveTypes] = useState<LeaveType[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchLeaveTypes = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await leaveTypesApi.getAll(includeInactive);
|
||||
setLeaveTypes(response.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error al cargar tipos de ausencia'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [includeInactive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchLeaveTypes();
|
||||
}
|
||||
}, [autoLoad, fetchLeaveTypes]);
|
||||
|
||||
const create = useCallback(async (data: CreateLeaveTypeDto): Promise<LeaveType> => {
|
||||
const result = await leaveTypesApi.create(data);
|
||||
await fetchLeaveTypes();
|
||||
return result;
|
||||
}, [fetchLeaveTypes]);
|
||||
|
||||
const update = useCallback(async (id: string, data: UpdateLeaveTypeDto): Promise<LeaveType> => {
|
||||
const result = await leaveTypesApi.update(id, data);
|
||||
await fetchLeaveTypes();
|
||||
return result;
|
||||
}, [fetchLeaveTypes]);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await leaveTypesApi.delete(id);
|
||||
await fetchLeaveTypes();
|
||||
}, [fetchLeaveTypes]);
|
||||
|
||||
return {
|
||||
leaveTypes,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchLeaveTypes,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
132
src/features/hr/hooks/useLeaves.ts
Normal file
132
src/features/hr/hooks/useLeaves.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { leavesApi } from '../api';
|
||||
import type {
|
||||
Leave,
|
||||
CreateLeaveDto,
|
||||
UpdateLeaveDto,
|
||||
LeaveFilters,
|
||||
} from '../types';
|
||||
|
||||
interface UseLeavesOptions {
|
||||
initialFilters?: LeaveFilters;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseLeavesReturn {
|
||||
leaves: Leave[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
filters: LeaveFilters;
|
||||
setFilters: (filters: LeaveFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
getById: (id: string) => Promise<Leave>;
|
||||
create: (data: CreateLeaveDto) => Promise<Leave>;
|
||||
update: (id: string, data: UpdateLeaveDto) => Promise<Leave>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
submit: (id: string) => Promise<Leave>;
|
||||
approve: (id: string) => Promise<Leave>;
|
||||
reject: (id: string, reason: string) => Promise<Leave>;
|
||||
cancel: (id: string) => Promise<Leave>;
|
||||
}
|
||||
|
||||
export function useLeaves(options: UseLeavesOptions = {}): UseLeavesReturn {
|
||||
const { initialFilters = {}, autoLoad = true } = options;
|
||||
|
||||
const [leaves, setLeaves] = useState<Leave[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<LeaveFilters>(initialFilters);
|
||||
|
||||
const fetchLeaves = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await leavesApi.getAll(filters);
|
||||
setLeaves(response.data);
|
||||
setTotal(response.total);
|
||||
setPage(response.page);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error al cargar ausencias'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchLeaves();
|
||||
}
|
||||
}, [autoLoad, fetchLeaves]);
|
||||
|
||||
const getById = useCallback(async (id: string): Promise<Leave> => {
|
||||
return leavesApi.getById(id);
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (data: CreateLeaveDto): Promise<Leave> => {
|
||||
const result = await leavesApi.create(data);
|
||||
await fetchLeaves();
|
||||
return result;
|
||||
}, [fetchLeaves]);
|
||||
|
||||
const update = useCallback(async (id: string, data: UpdateLeaveDto): Promise<Leave> => {
|
||||
const result = await leavesApi.update(id, data);
|
||||
await fetchLeaves();
|
||||
return result;
|
||||
}, [fetchLeaves]);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await leavesApi.delete(id);
|
||||
await fetchLeaves();
|
||||
}, [fetchLeaves]);
|
||||
|
||||
const submit = useCallback(async (id: string): Promise<Leave> => {
|
||||
const result = await leavesApi.submit(id);
|
||||
await fetchLeaves();
|
||||
return result;
|
||||
}, [fetchLeaves]);
|
||||
|
||||
const approve = useCallback(async (id: string): Promise<Leave> => {
|
||||
const result = await leavesApi.approve(id);
|
||||
await fetchLeaves();
|
||||
return result;
|
||||
}, [fetchLeaves]);
|
||||
|
||||
const reject = useCallback(async (id: string, reason: string): Promise<Leave> => {
|
||||
const result = await leavesApi.reject(id, reason);
|
||||
await fetchLeaves();
|
||||
return result;
|
||||
}, [fetchLeaves]);
|
||||
|
||||
const cancel = useCallback(async (id: string): Promise<Leave> => {
|
||||
const result = await leavesApi.cancel(id);
|
||||
await fetchLeaves();
|
||||
return result;
|
||||
}, [fetchLeaves]);
|
||||
|
||||
return {
|
||||
leaves,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchLeaves,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
submit,
|
||||
approve,
|
||||
reject,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
10
src/features/hr/index.ts
Normal file
10
src/features/hr/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// HR Feature - Barrel Export
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// API
|
||||
export * from './api';
|
||||
|
||||
// Hooks
|
||||
export * from './hooks';
|
||||
561
src/features/hr/types/hr.types.ts
Normal file
561
src/features/hr/types/hr.types.ts
Normal file
@ -0,0 +1,561 @@
|
||||
// HR Types - Employees, Departments, Contracts, Leaves
|
||||
|
||||
// ============================================================================
|
||||
// Enums
|
||||
// ============================================================================
|
||||
|
||||
export type EmployeeStatus = 'active' | 'inactive' | 'on_leave' | 'terminated';
|
||||
export type ContractType = 'permanent' | 'temporary' | 'contractor' | 'internship' | 'part_time';
|
||||
export type ContractStatus = 'draft' | 'active' | 'expired' | 'terminated' | 'cancelled';
|
||||
export type LeaveStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'cancelled';
|
||||
export type LeaveTypeCategory = 'vacation' | 'sick' | 'personal' | 'maternity' | 'paternity' | 'bereavement' | 'unpaid' | 'other';
|
||||
|
||||
// ============================================================================
|
||||
// Employee Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Employee {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
companyId: string;
|
||||
employeeNumber: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName?: string;
|
||||
fullName: string;
|
||||
userId?: string;
|
||||
birthDate?: string;
|
||||
gender?: string;
|
||||
maritalStatus?: string;
|
||||
nationality?: string;
|
||||
identificationId?: string;
|
||||
identificationType?: string;
|
||||
socialSecurityNumber?: string;
|
||||
taxId?: string;
|
||||
email?: string;
|
||||
workEmail?: string;
|
||||
phone?: string;
|
||||
workPhone?: string;
|
||||
mobile?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip?: string;
|
||||
country?: string;
|
||||
departmentId?: string;
|
||||
departmentName?: string;
|
||||
jobPositionId?: string;
|
||||
jobPositionName?: string;
|
||||
managerId?: string;
|
||||
managerName?: string;
|
||||
hireDate: string;
|
||||
terminationDate?: string;
|
||||
status: EmployeeStatus;
|
||||
bankName?: string;
|
||||
bankAccount?: string;
|
||||
bankClabe?: string;
|
||||
photoUrl?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateEmployeeDto {
|
||||
companyId: string;
|
||||
employeeNumber: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName?: string;
|
||||
userId?: string;
|
||||
birthDate?: string;
|
||||
gender?: string;
|
||||
maritalStatus?: string;
|
||||
nationality?: string;
|
||||
identificationId?: string;
|
||||
identificationType?: string;
|
||||
socialSecurityNumber?: string;
|
||||
taxId?: string;
|
||||
email?: string;
|
||||
workEmail?: string;
|
||||
phone?: string;
|
||||
workPhone?: string;
|
||||
mobile?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip?: string;
|
||||
country?: string;
|
||||
departmentId?: string;
|
||||
jobPositionId?: string;
|
||||
managerId?: string;
|
||||
hireDate: string;
|
||||
bankName?: string;
|
||||
bankAccount?: string;
|
||||
bankClabe?: string;
|
||||
photoUrl?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateEmployeeDto {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
middleName?: string;
|
||||
userId?: string;
|
||||
birthDate?: string;
|
||||
gender?: string;
|
||||
maritalStatus?: string;
|
||||
nationality?: string;
|
||||
identificationId?: string;
|
||||
identificationType?: string;
|
||||
socialSecurityNumber?: string;
|
||||
taxId?: string;
|
||||
email?: string;
|
||||
workEmail?: string;
|
||||
phone?: string;
|
||||
workPhone?: string;
|
||||
mobile?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip?: string;
|
||||
country?: string;
|
||||
departmentId?: string;
|
||||
jobPositionId?: string;
|
||||
managerId?: string;
|
||||
bankName?: string;
|
||||
bankAccount?: string;
|
||||
bankClabe?: string;
|
||||
photoUrl?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface EmployeeFilters {
|
||||
companyId?: string;
|
||||
departmentId?: string;
|
||||
status?: EmployeeStatus;
|
||||
managerId?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Department Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
parentId?: string;
|
||||
parentName?: string;
|
||||
managerId?: string;
|
||||
managerName?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
isActive: boolean;
|
||||
employeeCount?: number;
|
||||
children?: Department[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateDepartmentDto {
|
||||
companyId: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
parentId?: string;
|
||||
managerId?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDepartmentDto {
|
||||
name?: string;
|
||||
code?: string;
|
||||
parentId?: string | null;
|
||||
managerId?: string | null;
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface DepartmentFilters {
|
||||
companyId?: string;
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Job Position Types
|
||||
// ============================================================================
|
||||
|
||||
export interface JobPosition {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
departmentId?: string;
|
||||
departmentName?: string;
|
||||
description?: string;
|
||||
requirements?: string;
|
||||
responsibilities?: string;
|
||||
minSalary?: number;
|
||||
maxSalary?: number;
|
||||
isActive: boolean;
|
||||
employeeCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateJobPositionDto {
|
||||
name: string;
|
||||
departmentId?: string;
|
||||
description?: string;
|
||||
requirements?: string;
|
||||
responsibilities?: string;
|
||||
minSalary?: number;
|
||||
maxSalary?: number;
|
||||
}
|
||||
|
||||
export interface UpdateJobPositionDto {
|
||||
name?: string;
|
||||
departmentId?: string | null;
|
||||
description?: string | null;
|
||||
requirements?: string | null;
|
||||
responsibilities?: string | null;
|
||||
minSalary?: number | null;
|
||||
maxSalary?: number | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Contract Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Contract {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
companyId: string;
|
||||
employeeId: string;
|
||||
employeeName?: string;
|
||||
employeeNumber?: string;
|
||||
name: string;
|
||||
reference?: string;
|
||||
contractType: ContractType;
|
||||
status: ContractStatus;
|
||||
jobPositionId?: string;
|
||||
jobPositionName?: string;
|
||||
departmentId?: string;
|
||||
departmentName?: string;
|
||||
dateStart: string;
|
||||
dateEnd?: string;
|
||||
trialDateEnd?: string;
|
||||
wage: number;
|
||||
wageType?: string;
|
||||
currencyId?: string;
|
||||
currency?: string;
|
||||
hoursPerWeek?: number;
|
||||
vacationDays?: number;
|
||||
christmasBonusDays?: number;
|
||||
documentUrl?: string;
|
||||
notes?: string;
|
||||
activatedAt?: string;
|
||||
terminatedAt?: string;
|
||||
terminationReason?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateContractDto {
|
||||
companyId: string;
|
||||
employeeId: string;
|
||||
name: string;
|
||||
reference?: string;
|
||||
contractType: ContractType;
|
||||
jobPositionId?: string;
|
||||
departmentId?: string;
|
||||
dateStart: string;
|
||||
dateEnd?: string;
|
||||
trialDateEnd?: string;
|
||||
wage: number;
|
||||
wageType?: string;
|
||||
currencyId?: string;
|
||||
hoursPerWeek?: number;
|
||||
vacationDays?: number;
|
||||
christmasBonusDays?: number;
|
||||
documentUrl?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateContractDto {
|
||||
reference?: string | null;
|
||||
jobPositionId?: string | null;
|
||||
departmentId?: string | null;
|
||||
dateEnd?: string | null;
|
||||
trialDateEnd?: string | null;
|
||||
wage?: number;
|
||||
wageType?: string;
|
||||
currencyId?: string | null;
|
||||
hoursPerWeek?: number;
|
||||
vacationDays?: number;
|
||||
christmasBonusDays?: number;
|
||||
documentUrl?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface ContractFilters {
|
||||
companyId?: string;
|
||||
employeeId?: string;
|
||||
status?: ContractStatus;
|
||||
contractType?: ContractType;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Leave Type Types
|
||||
// ============================================================================
|
||||
|
||||
export interface LeaveType {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
leaveCategory: LeaveTypeCategory;
|
||||
requiresApproval: boolean;
|
||||
maxDays?: number;
|
||||
isPaid: boolean;
|
||||
color?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateLeaveTypeDto {
|
||||
name: string;
|
||||
code?: string;
|
||||
leaveType: LeaveTypeCategory;
|
||||
requiresApproval?: boolean;
|
||||
maxDays?: number;
|
||||
isPaid?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLeaveTypeDto {
|
||||
name?: string;
|
||||
code?: string | null;
|
||||
requiresApproval?: boolean;
|
||||
maxDays?: number | null;
|
||||
isPaid?: boolean;
|
||||
color?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Leave Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Leave {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
companyId: string;
|
||||
employeeId: string;
|
||||
employeeName?: string;
|
||||
employeeNumber?: string;
|
||||
leaveTypeId: string;
|
||||
leaveTypeName?: string;
|
||||
leaveTypeColor?: string;
|
||||
name?: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
daysRequested: number;
|
||||
status: LeaveStatus;
|
||||
approverId?: string;
|
||||
approverName?: string;
|
||||
approvedAt?: string;
|
||||
rejectionReason?: string;
|
||||
description?: string;
|
||||
submittedAt?: string;
|
||||
cancelledAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateLeaveDto {
|
||||
companyId: string;
|
||||
employeeId: string;
|
||||
leaveTypeId: string;
|
||||
name?: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLeaveDto {
|
||||
leaveTypeId?: string;
|
||||
name?: string | null;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface LeaveFilters {
|
||||
companyId?: string;
|
||||
employeeId?: string;
|
||||
leaveTypeId?: string;
|
||||
status?: LeaveStatus;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response Types
|
||||
// ============================================================================
|
||||
|
||||
export interface EmployeesResponse {
|
||||
data: Employee[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface DepartmentsResponse {
|
||||
data: Department[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface JobPositionsResponse {
|
||||
data: JobPosition[];
|
||||
}
|
||||
|
||||
export interface ContractsResponse {
|
||||
data: Contract[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface LeaveTypesResponse {
|
||||
data: LeaveType[];
|
||||
}
|
||||
|
||||
export interface LeavesResponse {
|
||||
data: Leave[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants and Labels
|
||||
// ============================================================================
|
||||
|
||||
export const EMPLOYEE_STATUS_LABELS: Record<EmployeeStatus, string> = {
|
||||
active: 'Activo',
|
||||
inactive: 'Inactivo',
|
||||
on_leave: 'Con permiso',
|
||||
terminated: 'Dado de baja',
|
||||
};
|
||||
|
||||
export const EMPLOYEE_STATUS_COLORS: Record<EmployeeStatus, string> = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
inactive: 'bg-gray-100 text-gray-600',
|
||||
on_leave: 'bg-yellow-100 text-yellow-700',
|
||||
terminated: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
export const CONTRACT_TYPE_LABELS: Record<ContractType, string> = {
|
||||
permanent: 'Indefinido',
|
||||
temporary: 'Temporal',
|
||||
contractor: 'Por honorarios',
|
||||
internship: 'Practicas',
|
||||
part_time: 'Medio tiempo',
|
||||
};
|
||||
|
||||
export const CONTRACT_STATUS_LABELS: Record<ContractStatus, string> = {
|
||||
draft: 'Borrador',
|
||||
active: 'Activo',
|
||||
expired: 'Vencido',
|
||||
terminated: 'Terminado',
|
||||
cancelled: 'Cancelado',
|
||||
};
|
||||
|
||||
export const CONTRACT_STATUS_COLORS: Record<ContractStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600',
|
||||
active: 'bg-green-100 text-green-700',
|
||||
expired: 'bg-yellow-100 text-yellow-700',
|
||||
terminated: 'bg-red-100 text-red-700',
|
||||
cancelled: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
export const LEAVE_STATUS_LABELS: Record<LeaveStatus, string> = {
|
||||
draft: 'Borrador',
|
||||
submitted: 'Enviada',
|
||||
approved: 'Aprobada',
|
||||
rejected: 'Rechazada',
|
||||
cancelled: 'Cancelada',
|
||||
};
|
||||
|
||||
export const LEAVE_STATUS_COLORS: Record<LeaveStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600',
|
||||
submitted: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
cancelled: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
export const LEAVE_TYPE_CATEGORY_LABELS: Record<LeaveTypeCategory, string> = {
|
||||
vacation: 'Vacaciones',
|
||||
sick: 'Enfermedad',
|
||||
personal: 'Personal',
|
||||
maternity: 'Maternidad',
|
||||
paternity: 'Paternidad',
|
||||
bereavement: 'Luto',
|
||||
unpaid: 'Sin goce',
|
||||
other: 'Otro',
|
||||
};
|
||||
|
||||
export const WAGE_TYPE_LABELS: Record<string, string> = {
|
||||
hourly: 'Por hora',
|
||||
daily: 'Diario',
|
||||
weekly: 'Semanal',
|
||||
biweekly: 'Quincenal',
|
||||
monthly: 'Mensual',
|
||||
annual: 'Anual',
|
||||
};
|
||||
|
||||
export const GENDER_OPTIONS = [
|
||||
{ value: 'male', label: 'Masculino' },
|
||||
{ value: 'female', label: 'Femenino' },
|
||||
{ value: 'other', label: 'Otro' },
|
||||
];
|
||||
|
||||
export const MARITAL_STATUS_OPTIONS = [
|
||||
{ value: 'single', label: 'Soltero/a' },
|
||||
{ value: 'married', label: 'Casado/a' },
|
||||
{ value: 'divorced', label: 'Divorciado/a' },
|
||||
{ value: 'widowed', label: 'Viudo/a' },
|
||||
{ value: 'other', label: 'Otro' },
|
||||
];
|
||||
1
src/features/hr/types/index.ts
Normal file
1
src/features/hr/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './hr.types';
|
||||
611
src/pages/hr/ContractsPage.tsx
Normal file
611
src/pages/hr/ContractsPage.tsx
Normal file
@ -0,0 +1,611 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Search,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
PlayCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { Modal, ConfirmModal } from '@components/organisms/Modal';
|
||||
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useContracts, useEmployees, useDepartments, useJobPositions } from '@features/hr/hooks';
|
||||
import type { Contract, ContractStatus, ContractType, CreateContractDto } from '@features/hr/types';
|
||||
import {
|
||||
CONTRACT_STATUS_LABELS,
|
||||
CONTRACT_STATUS_COLORS,
|
||||
CONTRACT_TYPE_LABELS,
|
||||
WAGE_TYPE_LABELS,
|
||||
} from '@features/hr/types';
|
||||
import { formatDate, formatCurrency } from '@utils/formatters';
|
||||
|
||||
export function ContractsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<ContractStatus | ''>('');
|
||||
const [selectedType, setSelectedType] = useState<ContractType | ''>('');
|
||||
const [contractToDelete, setContractToDelete] = useState<Contract | null>(null);
|
||||
const [contractToActivate, setContractToActivate] = useState<Contract | null>(null);
|
||||
const [contractToTerminate, setContractToTerminate] = useState<Contract | null>(null);
|
||||
const [contractToCancel, setContractToCancel] = useState<Contract | null>(null);
|
||||
const [showNewModal, setShowNewModal] = useState(false);
|
||||
|
||||
const [newContract, setNewContract] = useState<Partial<CreateContractDto>>({
|
||||
contractType: 'permanent',
|
||||
wageType: 'monthly',
|
||||
});
|
||||
|
||||
const {
|
||||
contracts,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
setFilters,
|
||||
refresh,
|
||||
create,
|
||||
remove,
|
||||
activate,
|
||||
terminate,
|
||||
cancel,
|
||||
} = useContracts({
|
||||
initialFilters: {
|
||||
search: searchTerm,
|
||||
status: selectedStatus || undefined,
|
||||
contractType: selectedType || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { employees } = useEmployees({ initialFilters: { status: 'active' } });
|
||||
const { departments } = useDepartments();
|
||||
const { positions } = useJobPositions();
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setFilters({ search: value });
|
||||
};
|
||||
|
||||
const handleStatusFilter = (status: ContractStatus | '') => {
|
||||
setSelectedStatus(status);
|
||||
setFilters({ status: status || undefined });
|
||||
};
|
||||
|
||||
const handleTypeFilter = (type: ContractType | '') => {
|
||||
setSelectedType(type);
|
||||
setFilters({ contractType: type || undefined });
|
||||
};
|
||||
|
||||
const columns: Column<Contract>[] = [
|
||||
{
|
||||
key: 'contract',
|
||||
header: 'Contrato',
|
||||
render: (contract) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{contract.name}</div>
|
||||
{contract.reference && <div className="text-sm text-gray-500">{contract.reference}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'employee',
|
||||
header: 'Empleado',
|
||||
render: (contract) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{contract.employeeName}</div>
|
||||
<div className="text-sm text-gray-500">{contract.employeeNumber}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Tipo',
|
||||
render: (contract) => (
|
||||
<span className="text-sm text-gray-600">{CONTRACT_TYPE_LABELS[contract.contractType]}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Estado',
|
||||
render: (contract) => (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${CONTRACT_STATUS_COLORS[contract.status]}`}>
|
||||
{CONTRACT_STATUS_LABELS[contract.status]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
header: 'Vigencia',
|
||||
render: (contract) => (
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-900">{formatDate(contract.dateStart, 'short')}</div>
|
||||
<div className="text-gray-500">
|
||||
{contract.dateEnd ? formatDate(contract.dateEnd, 'short') : 'Indefinido'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'wage',
|
||||
header: 'Salario',
|
||||
render: (contract) => (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{formatCurrency(contract.wage, contract.currency || 'MXN')}
|
||||
</div>
|
||||
<div className="text-gray-500">{WAGE_TYPE_LABELS[contract.wageType || 'monthly']}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (contract) => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/hr/employees/${contract.employeeId}`),
|
||||
},
|
||||
];
|
||||
|
||||
if (contract.status === 'draft') {
|
||||
items.push({
|
||||
key: 'activate',
|
||||
label: 'Activar',
|
||||
icon: <PlayCircle className="h-4 w-4" />,
|
||||
onClick: () => setContractToActivate(contract),
|
||||
});
|
||||
items.push({
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit2 className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/hr/contracts/${contract.id}/edit`),
|
||||
});
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: 'Eliminar',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setContractToDelete(contract),
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.status === 'active') {
|
||||
items.push({
|
||||
key: 'terminate',
|
||||
label: 'Terminar',
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
onClick: () => setContractToTerminate(contract),
|
||||
});
|
||||
items.push({
|
||||
key: 'cancel',
|
||||
label: 'Cancelar',
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setContractToCancel(contract),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={items}
|
||||
align="right"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleDeleteContract = async () => {
|
||||
if (contractToDelete) {
|
||||
await remove(contractToDelete.id);
|
||||
setContractToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivateContract = async () => {
|
||||
if (contractToActivate) {
|
||||
await activate(contractToActivate.id);
|
||||
setContractToActivate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminateContract = async () => {
|
||||
if (contractToTerminate) {
|
||||
const today = new Date().toISOString().split('T')[0] as string;
|
||||
await terminate(contractToTerminate.id, today);
|
||||
setContractToTerminate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelContract = async () => {
|
||||
if (contractToCancel) {
|
||||
await cancel(contractToCancel.id);
|
||||
setContractToCancel(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateContract = async () => {
|
||||
if (!newContract.employeeId || !newContract.name || !newContract.dateStart || !newContract.wage) {
|
||||
return;
|
||||
}
|
||||
await create(newContract as CreateContractDto);
|
||||
setShowNewModal(false);
|
||||
setNewContract({ contractType: 'permanent', wageType: 'monthly' });
|
||||
};
|
||||
|
||||
const activeCount = contracts.filter(c => c.status === 'active').length;
|
||||
const draftCount = contracts.filter(c => c.status === 'draft').length;
|
||||
const expiredCount = contracts.filter(c => c.status === 'expired').length;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Recursos Humanos', href: '/hr' },
|
||||
{ label: 'Contratos' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Contratos</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona los contratos laborales de tus empleados
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button onClick={() => setShowNewModal(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo contrato
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Total</div>
|
||||
<div className="text-xl font-bold text-blue-600">{total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleStatusFilter('active')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Activos</div>
|
||||
<div className="text-xl font-bold text-green-600">{activeCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleStatusFilter('draft')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||
<FileText className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Borradores</div>
|
||||
<div className="text-xl font-bold text-gray-600">{draftCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleStatusFilter('expired')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100">
|
||||
<XCircle className="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Vencidos</div>
|
||||
<div className="text-xl font-bold text-yellow-600">{expiredCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lista de Contratos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar contratos..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => handleStatusFilter(e.target.value as ContractStatus | '')}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{Object.entries(CONTRACT_STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => handleTypeFilter(e.target.value as ContractType | '')}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
{Object.entries(CONTRACT_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{(searchTerm || selectedStatus || selectedType) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedStatus('');
|
||||
setSelectedType('');
|
||||
setFilters({});
|
||||
}}
|
||||
>
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contracts.length === 0 && !isLoading ? (
|
||||
<NoDataEmptyState entityName="contratos" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={contracts}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
limit: 20,
|
||||
onPageChange: (p) => setFilters({ page: p }),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={showNewModal}
|
||||
onClose={() => setShowNewModal(false)}
|
||||
title="Nuevo Contrato"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Empleado *</label>
|
||||
<select
|
||||
value={newContract.employeeId || ''}
|
||||
onChange={(e) => setNewContract({ ...newContract, employeeId: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar empleado</option>
|
||||
{employees.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.fullName} ({emp.employeeNumber})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Nombre del contrato *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newContract.name || ''}
|
||||
onChange={(e) => setNewContract({ ...newContract, name: e.target.value })}
|
||||
placeholder="Ej: Contrato Indefinido 2026"
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Referencia</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newContract.reference || ''}
|
||||
onChange={(e) => setNewContract({ ...newContract, reference: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Tipo de contrato *</label>
|
||||
<select
|
||||
value={newContract.contractType || 'permanent'}
|
||||
onChange={(e) => setNewContract({ ...newContract, contractType: e.target.value as ContractType })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
{Object.entries(CONTRACT_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Departamento</label>
|
||||
<select
|
||||
value={newContract.departmentId || ''}
|
||||
onChange={(e) => setNewContract({ ...newContract, departmentId: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{departments.map((dept) => (
|
||||
<option key={dept.id} value={dept.id}>{dept.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Puesto</label>
|
||||
<select
|
||||
value={newContract.jobPositionId || ''}
|
||||
onChange={(e) => setNewContract({ ...newContract, jobPositionId: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{positions.map((pos) => (
|
||||
<option key={pos.id} value={pos.id}>{pos.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Fecha de inicio *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newContract.dateStart || ''}
|
||||
onChange={(e) => setNewContract({ ...newContract, dateStart: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Fecha de fin</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newContract.dateEnd || ''}
|
||||
onChange={(e) => setNewContract({ ...newContract, dateEnd: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Salario *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newContract.wage || ''}
|
||||
onChange={(e) => setNewContract({ ...newContract, wage: Number(e.target.value) })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Tipo de salario</label>
|
||||
<select
|
||||
value={newContract.wageType || 'monthly'}
|
||||
onChange={(e) => setNewContract({ ...newContract, wageType: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
{Object.entries(WAGE_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Horas por semana</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newContract.hoursPerWeek || 48}
|
||||
onChange={(e) => setNewContract({ ...newContract, hoursPerWeek: Number(e.target.value) })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={() => setShowNewModal(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateContract}
|
||||
disabled={!newContract.employeeId || !newContract.name || !newContract.dateStart || !newContract.wage}
|
||||
>
|
||||
Crear contrato
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!contractToDelete}
|
||||
onClose={() => setContractToDelete(null)}
|
||||
onConfirm={handleDeleteContract}
|
||||
title="Eliminar contrato"
|
||||
message={`¿Eliminar el contrato "${contractToDelete?.name}"? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!contractToActivate}
|
||||
onClose={() => setContractToActivate(null)}
|
||||
onConfirm={handleActivateContract}
|
||||
title="Activar contrato"
|
||||
message={`¿Activar el contrato "${contractToActivate?.name}"?`}
|
||||
variant="success"
|
||||
confirmText="Activar"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!contractToTerminate}
|
||||
onClose={() => setContractToTerminate(null)}
|
||||
onConfirm={handleTerminateContract}
|
||||
title="Terminar contrato"
|
||||
message={`¿Terminar el contrato "${contractToTerminate?.name}"? Se registrara con fecha de hoy.`}
|
||||
variant="warning"
|
||||
confirmText="Terminar"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!contractToCancel}
|
||||
onClose={() => setContractToCancel(null)}
|
||||
onConfirm={handleCancelContract}
|
||||
title="Cancelar contrato"
|
||||
message={`¿Cancelar el contrato "${contractToCancel?.name}"? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Cancelar contrato"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContractsPage;
|
||||
570
src/pages/hr/DepartmentsPage.tsx
Normal file
570
src/pages/hr/DepartmentsPage.tsx
Normal file
@ -0,0 +1,570 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Users,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||
import { Modal, ConfirmModal } from '@components/organisms/Modal';
|
||||
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { useDepartments, useJobPositions, useEmployees } from '@features/hr/hooks';
|
||||
import type { Department, CreateDepartmentDto, UpdateDepartmentDto, JobPosition, CreateJobPositionDto, UpdateJobPositionDto } from '@features/hr/types';
|
||||
|
||||
interface DepartmentNodeProps {
|
||||
department: Department;
|
||||
level: number;
|
||||
expanded: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onEdit: (dept: Department) => void;
|
||||
onDelete: (dept: Department) => void;
|
||||
allDepartments: Department[];
|
||||
}
|
||||
|
||||
function DepartmentNode({ department, level, expanded, onToggle, onEdit, onDelete, allDepartments }: DepartmentNodeProps) {
|
||||
const children = allDepartments.filter(d => d.parentId === department.id);
|
||||
const hasChildren = children.length > 0;
|
||||
const isExpanded = expanded.has(department.id);
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit2 className="h-4 w-4" />,
|
||||
onClick: () => onEdit(department),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Eliminar',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => onDelete(department),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-lg border bg-white p-3 hover:bg-gray-50 transition-colors ${
|
||||
!department.isActive ? 'opacity-60' : ''
|
||||
}`}
|
||||
style={{ marginLeft: `${level * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => onToggle(department.id)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded hover:bg-gray-200"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6" />
|
||||
)}
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: department.color ? `${department.color}20` : '#E5E7EB' }}
|
||||
>
|
||||
<Building2
|
||||
className="h-5 w-5"
|
||||
style={{ color: department.color || '#6B7280' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{department.name}</span>
|
||||
{department.code && (
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500">{department.code}</span>
|
||||
)}
|
||||
{!department.isActive && (
|
||||
<span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-600">Inactivo</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500">
|
||||
{department.managerName && <span>Gerente: {department.managerName}</span>}
|
||||
{department.employeeCount !== undefined && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{department.employeeCount} empleados
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={items}
|
||||
align="right"
|
||||
/>
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{children.map(child => (
|
||||
<DepartmentNode
|
||||
key={child.id}
|
||||
department={child}
|
||||
level={level + 1}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
allDepartments={allDepartments}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DepartmentsPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [showDeptModal, setShowDeptModal] = useState(false);
|
||||
const [showPositionModal, setShowPositionModal] = useState(false);
|
||||
const [editingDept, setEditingDept] = useState<Department | null>(null);
|
||||
const [editingPosition, setEditingPosition] = useState<JobPosition | null>(null);
|
||||
const [deptToDelete, setDeptToDelete] = useState<Department | null>(null);
|
||||
const [positionToDelete, setPositionToDelete] = useState<JobPosition | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'departments' | 'positions'>('departments');
|
||||
|
||||
const [deptForm, setDeptForm] = useState<Partial<CreateDepartmentDto>>({ companyId: '' });
|
||||
const [positionForm, setPositionForm] = useState<Partial<CreateJobPositionDto>>({});
|
||||
|
||||
const { departments, isLoading, error, refresh, create: createDept, update: updateDept, remove: removeDept } = useDepartments();
|
||||
const { positions, isLoading: loadingPositions, refresh: refreshPositions, create: createPosition, update: updatePosition, remove: removePosition } = useJobPositions({ includeInactive: true });
|
||||
const { employees } = useEmployees({ initialFilters: { status: 'active' } });
|
||||
|
||||
const rootDepartments = departments.filter(d => !d.parentId);
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
const newExpanded = new Set(expanded);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
}
|
||||
setExpanded(newExpanded);
|
||||
};
|
||||
|
||||
const handleEditDept = (dept: Department) => {
|
||||
setEditingDept(dept);
|
||||
setDeptForm({
|
||||
companyId: dept.companyId,
|
||||
name: dept.name,
|
||||
code: dept.code || '',
|
||||
parentId: dept.parentId || '',
|
||||
managerId: dept.managerId || '',
|
||||
description: dept.description || '',
|
||||
color: dept.color || '#3B82F6',
|
||||
});
|
||||
setShowDeptModal(true);
|
||||
};
|
||||
|
||||
const handleEditPosition = (position: JobPosition) => {
|
||||
setEditingPosition(position);
|
||||
setPositionForm({
|
||||
name: position.name,
|
||||
departmentId: position.departmentId || '',
|
||||
description: position.description || '',
|
||||
requirements: position.requirements || '',
|
||||
responsibilities: position.responsibilities || '',
|
||||
minSalary: position.minSalary,
|
||||
maxSalary: position.maxSalary,
|
||||
});
|
||||
setShowPositionModal(true);
|
||||
};
|
||||
|
||||
const handleSaveDept = async () => {
|
||||
if (!deptForm.name) return;
|
||||
|
||||
if (editingDept) {
|
||||
const updateData: UpdateDepartmentDto = {
|
||||
name: deptForm.name,
|
||||
code: deptForm.code || undefined,
|
||||
parentId: deptForm.parentId || undefined,
|
||||
managerId: deptForm.managerId || undefined,
|
||||
description: deptForm.description || undefined,
|
||||
color: deptForm.color || undefined,
|
||||
};
|
||||
await updateDept(editingDept.id, updateData);
|
||||
} else {
|
||||
await createDept(deptForm as CreateDepartmentDto);
|
||||
}
|
||||
setShowDeptModal(false);
|
||||
setEditingDept(null);
|
||||
setDeptForm({ companyId: '' });
|
||||
};
|
||||
|
||||
const handleSavePosition = async () => {
|
||||
if (!positionForm.name) return;
|
||||
|
||||
if (editingPosition) {
|
||||
const updateData: UpdateJobPositionDto = {
|
||||
name: positionForm.name,
|
||||
departmentId: positionForm.departmentId || null,
|
||||
description: positionForm.description || null,
|
||||
requirements: positionForm.requirements || null,
|
||||
responsibilities: positionForm.responsibilities || null,
|
||||
minSalary: positionForm.minSalary ?? null,
|
||||
maxSalary: positionForm.maxSalary ?? null,
|
||||
};
|
||||
await updatePosition(editingPosition.id, updateData);
|
||||
} else {
|
||||
await createPosition(positionForm as CreateJobPositionDto);
|
||||
}
|
||||
setShowPositionModal(false);
|
||||
setEditingPosition(null);
|
||||
setPositionForm({});
|
||||
};
|
||||
|
||||
const handleDeleteDept = async () => {
|
||||
if (deptToDelete) {
|
||||
await removeDept(deptToDelete.id);
|
||||
setDeptToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePosition = async () => {
|
||||
if (positionToDelete) {
|
||||
await removePosition(positionToDelete.id);
|
||||
setPositionToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredDepartments = searchTerm
|
||||
? departments.filter(d => d.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
: rootDepartments;
|
||||
|
||||
const filteredPositions = searchTerm
|
||||
? positions.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
: positions;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Recursos Humanos', href: '/hr' },
|
||||
{ label: 'Departamentos' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Departamentos y Puestos</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona la estructura organizacional de tu empresa
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => { refresh(); refreshPositions(); }} disabled={isLoading || loadingPositions}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading || loadingPositions ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
{activeTab === 'departments' ? (
|
||||
<Button onClick={() => { setEditingDept(null); setDeptForm({ companyId: '' }); setShowDeptModal(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo departamento
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => { setEditingPosition(null); setPositionForm({}); setShowPositionModal(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo puesto
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
className={`px-4 py-2 text-sm font-medium ${activeTab === 'departments' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
onClick={() => setActiveTab('departments')}
|
||||
>
|
||||
Departamentos ({departments.length})
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 text-sm font-medium ${activeTab === 'positions' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
onClick={() => setActiveTab('positions')}
|
||||
>
|
||||
Puestos ({positions.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{activeTab === 'departments' ? 'Estructura Organizacional' : 'Catalogo de Puestos'}</CardTitle>
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={activeTab === 'departments' ? 'Buscar departamentos...' : 'Buscar puestos...'}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading || loadingPositions ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : activeTab === 'departments' ? (
|
||||
filteredDepartments.length === 0 ? (
|
||||
<NoDataEmptyState entityName="departamentos" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(searchTerm ? filteredDepartments : rootDepartments).map(dept => (
|
||||
<DepartmentNode
|
||||
key={dept.id}
|
||||
department={dept}
|
||||
level={0}
|
||||
expanded={expanded}
|
||||
onToggle={handleToggle}
|
||||
onEdit={handleEditDept}
|
||||
onDelete={setDeptToDelete}
|
||||
allDepartments={departments}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : filteredPositions.length === 0 ? (
|
||||
<NoDataEmptyState entityName="puestos" />
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPositions.map(position => (
|
||||
<div
|
||||
key={position.id}
|
||||
className={`rounded-lg border bg-white p-4 hover:shadow-md transition-shadow ${!position.isActive ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{position.name}</h3>
|
||||
{position.departmentName && (
|
||||
<p className="text-sm text-gray-500">{position.departmentName}</p>
|
||||
)}
|
||||
{!position.isActive && (
|
||||
<span className="mt-1 inline-block rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-600">Inactivo</span>
|
||||
)}
|
||||
</div>
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={[
|
||||
{ key: 'edit', label: 'Editar', icon: <Edit2 className="h-4 w-4" />, onClick: () => handleEditPosition(position) },
|
||||
{ key: 'delete', label: 'Eliminar', icon: <Trash2 className="h-4 w-4" />, danger: true, onClick: () => setPositionToDelete(position) },
|
||||
]}
|
||||
align="right"
|
||||
/>
|
||||
</div>
|
||||
{position.description && (
|
||||
<p className="mt-2 text-sm text-gray-600 line-clamp-2">{position.description}</p>
|
||||
)}
|
||||
{(position.minSalary || position.maxSalary) && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Rango: ${position.minSalary?.toLocaleString() || '0'} - ${position.maxSalary?.toLocaleString() || 'N/A'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeptModal}
|
||||
onClose={() => { setShowDeptModal(false); setEditingDept(null); }}
|
||||
title={editingDept ? 'Editar Departamento' : 'Nuevo Departamento'}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Nombre *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deptForm.name || ''}
|
||||
onChange={(e) => setDeptForm({ ...deptForm, name: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Codigo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deptForm.code || ''}
|
||||
onChange={(e) => setDeptForm({ ...deptForm, code: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Departamento padre</label>
|
||||
<select
|
||||
value={deptForm.parentId || ''}
|
||||
onChange={(e) => setDeptForm({ ...deptForm, parentId: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Ninguno (raiz)</option>
|
||||
{departments.filter(d => d.id !== editingDept?.id).map((dept) => (
|
||||
<option key={dept.id} value={dept.id}>{dept.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Gerente</label>
|
||||
<select
|
||||
value={deptForm.managerId || ''}
|
||||
onChange={(e) => setDeptForm({ ...deptForm, managerId: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{employees.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={deptForm.color || '#3B82F6'}
|
||||
onChange={(e) => setDeptForm({ ...deptForm, color: e.target.value })}
|
||||
className="mt-1 h-10 w-full rounded-md border border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Descripcion</label>
|
||||
<textarea
|
||||
value={deptForm.description || ''}
|
||||
onChange={(e) => setDeptForm({ ...deptForm, description: e.target.value })}
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={() => { setShowDeptModal(false); setEditingDept(null); }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSaveDept} disabled={!deptForm.name}>
|
||||
{editingDept ? 'Guardar' : 'Crear'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showPositionModal}
|
||||
onClose={() => { setShowPositionModal(false); setEditingPosition(null); }}
|
||||
title={editingPosition ? 'Editar Puesto' : 'Nuevo Puesto'}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Nombre *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={positionForm.name || ''}
|
||||
onChange={(e) => setPositionForm({ ...positionForm, name: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Departamento</label>
|
||||
<select
|
||||
value={positionForm.departmentId || ''}
|
||||
onChange={(e) => setPositionForm({ ...positionForm, departmentId: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{departments.map((dept) => (
|
||||
<option key={dept.id} value={dept.id}>{dept.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Salario minimo</label>
|
||||
<input
|
||||
type="number"
|
||||
value={positionForm.minSalary || ''}
|
||||
onChange={(e) => setPositionForm({ ...positionForm, minSalary: Number(e.target.value) || undefined })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Salario maximo</label>
|
||||
<input
|
||||
type="number"
|
||||
value={positionForm.maxSalary || ''}
|
||||
onChange={(e) => setPositionForm({ ...positionForm, maxSalary: Number(e.target.value) || undefined })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Descripcion</label>
|
||||
<textarea
|
||||
value={positionForm.description || ''}
|
||||
onChange={(e) => setPositionForm({ ...positionForm, description: e.target.value })}
|
||||
rows={2}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={() => { setShowPositionModal(false); setEditingPosition(null); }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSavePosition} disabled={!positionForm.name}>
|
||||
{editingPosition ? 'Guardar' : 'Crear'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deptToDelete}
|
||||
onClose={() => setDeptToDelete(null)}
|
||||
onConfirm={handleDeleteDept}
|
||||
title="Eliminar departamento"
|
||||
message={`¿Eliminar el departamento "${deptToDelete?.name}"? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!positionToDelete}
|
||||
onClose={() => setPositionToDelete(null)}
|
||||
onConfirm={handleDeletePosition}
|
||||
title="Eliminar puesto"
|
||||
message={`¿Eliminar el puesto "${positionToDelete?.name}"? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DepartmentsPage;
|
||||
482
src/pages/hr/EmployeeDetailPage.tsx
Normal file
482
src/pages/hr/EmployeeDetailPage.tsx
Normal file
@ -0,0 +1,482 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
User,
|
||||
Building2,
|
||||
Briefcase,
|
||||
Phone,
|
||||
Mail,
|
||||
MapPin,
|
||||
Calendar,
|
||||
FileText,
|
||||
Users,
|
||||
Edit2,
|
||||
UserMinus,
|
||||
UserCheck,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
CreditCard,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { Tabs, TabList, Tab, TabPanels, TabPanel } from '@components/organisms/Tabs';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useEmployee } from '@features/hr/hooks';
|
||||
import { useContracts } from '@features/hr/hooks';
|
||||
import { useLeaves } from '@features/hr/hooks';
|
||||
import type { Contract, Leave } from '@features/hr/types';
|
||||
import {
|
||||
EMPLOYEE_STATUS_LABELS,
|
||||
EMPLOYEE_STATUS_COLORS,
|
||||
CONTRACT_STATUS_LABELS,
|
||||
CONTRACT_STATUS_COLORS,
|
||||
CONTRACT_TYPE_LABELS,
|
||||
LEAVE_STATUS_LABELS,
|
||||
LEAVE_STATUS_COLORS,
|
||||
} from '@features/hr/types';
|
||||
import { formatDate, formatCurrency } from '@utils/formatters';
|
||||
|
||||
export function EmployeeDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [showTerminateModal, setShowTerminateModal] = useState(false);
|
||||
const [showReactivateModal, setShowReactivateModal] = useState(false);
|
||||
|
||||
const {
|
||||
employee,
|
||||
subordinates,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
loadSubordinates,
|
||||
terminate,
|
||||
reactivate,
|
||||
} = useEmployee({ employeeId: id });
|
||||
|
||||
const { contracts, isLoading: loadingContracts } = useContracts({
|
||||
initialFilters: { employeeId: id },
|
||||
autoLoad: !!id,
|
||||
});
|
||||
|
||||
const { leaves, isLoading: loadingLeaves } = useLeaves({
|
||||
initialFilters: { employeeId: id },
|
||||
autoLoad: !!id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadSubordinates();
|
||||
}
|
||||
}, [id, loadSubordinates]);
|
||||
|
||||
const handleTerminate = async () => {
|
||||
const today = new Date().toISOString().split('T')[0] as string;
|
||||
await terminate(today);
|
||||
setShowTerminateModal(false);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleReactivate = async () => {
|
||||
await reactivate();
|
||||
setShowReactivateModal(false);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const contractColumns: Column<Contract>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Contrato',
|
||||
render: (contract) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{contract.name}</div>
|
||||
{contract.reference && <div className="text-sm text-gray-500">{contract.reference}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Tipo',
|
||||
render: (contract) => (
|
||||
<span className="text-sm text-gray-600">{CONTRACT_TYPE_LABELS[contract.contractType]}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Estado',
|
||||
render: (contract) => (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${CONTRACT_STATUS_COLORS[contract.status]}`}>
|
||||
{CONTRACT_STATUS_LABELS[contract.status]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
header: 'Vigencia',
|
||||
render: (contract) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{formatDate(contract.dateStart, 'short')} - {contract.dateEnd ? formatDate(contract.dateEnd, 'short') : 'Indefinido'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'wage',
|
||||
header: 'Salario',
|
||||
render: (contract) => (
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{formatCurrency(contract.wage, contract.currency || 'MXN')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const leaveColumns: Column<Leave>[] = [
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Tipo',
|
||||
render: (leave) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{leave.leaveTypeColor && (
|
||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: leave.leaveTypeColor }} />
|
||||
)}
|
||||
<span className="text-sm text-gray-900">{leave.leaveTypeName}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
header: 'Periodo',
|
||||
render: (leave) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{formatDate(leave.dateFrom, 'short')} - {formatDate(leave.dateTo, 'short')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'days',
|
||||
header: 'Dias',
|
||||
render: (leave) => (
|
||||
<span className="text-sm font-medium text-gray-900">{leave.daysRequested}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Estado',
|
||||
render: (leave) => (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${LEAVE_STATUS_COLORS[leave.status]}`}>
|
||||
{LEAVE_STATUS_LABELS[leave.status]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !employee) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Recursos Humanos', href: '/hr' },
|
||||
{ label: 'Empleados', href: '/hr/employees' },
|
||||
{ label: employee.fullName },
|
||||
]} />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/hr/employees')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-100">
|
||||
{employee.photoUrl ? (
|
||||
<img src={employee.photoUrl} alt={employee.fullName} className="h-16 w-16 rounded-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xl font-medium text-blue-600">
|
||||
{employee.firstName[0]}{employee.lastName[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{employee.fullName}</h1>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500">
|
||||
<span>{employee.employeeNumber}</span>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${EMPLOYEE_STATUS_COLORS[employee.status]}`}>
|
||||
{EMPLOYEE_STATUS_LABELS[employee.status]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{employee.status === 'active' && (
|
||||
<Button variant="outline" onClick={() => setShowTerminateModal(true)}>
|
||||
<UserMinus className="mr-2 h-4 w-4" />
|
||||
Dar de baja
|
||||
</Button>
|
||||
)}
|
||||
{(employee.status === 'terminated' || employee.status === 'inactive') && (
|
||||
<Button variant="outline" onClick={() => setShowReactivateModal(true)}>
|
||||
<UserCheck className="mr-2 h-4 w-4" />
|
||||
Reactivar
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => navigate(`/hr/employees/${employee.id}/edit`)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Informacion Personal
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Fecha de nacimiento</p>
|
||||
<p className="font-medium">{employee.birthDate ? formatDate(employee.birthDate, 'short') : '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Genero</p>
|
||||
<p className="font-medium">{employee.gender || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Estado civil</p>
|
||||
<p className="font-medium">{employee.maritalStatus || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Nacionalidad</p>
|
||||
<p className="font-medium">{employee.nationality || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm text-gray-500">RFC</p>
|
||||
<p className="font-medium">{employee.taxId || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">NSS</p>
|
||||
<p className="font-medium">{employee.socialSecurityNumber || '-'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5" />
|
||||
Informacion Laboral
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Departamento</p>
|
||||
<p className="font-medium">{employee.departmentName || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Briefcase className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Puesto</p>
|
||||
<p className="font-medium">{employee.jobPositionName || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Reporta a</p>
|
||||
<p className="font-medium">{employee.managerName || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Fecha de ingreso</p>
|
||||
<p className="font-medium">{formatDate(employee.hireDate, 'long')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{employee.terminationDate && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Fecha de baja</p>
|
||||
<p className="font-medium text-red-600">{formatDate(employee.terminationDate, 'long')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Phone className="h-5 w-5" />
|
||||
Contacto
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Email laboral</p>
|
||||
<p className="font-medium">{employee.workEmail || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Telefono laboral</p>
|
||||
<p className="font-medium">{employee.workPhone || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Celular</p>
|
||||
<p className="font-medium">{employee.mobile || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Direccion</p>
|
||||
<p className="font-medium">
|
||||
{[employee.street, employee.city, employee.state, employee.zip].filter(Boolean).join(', ') || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm font-medium text-gray-700">Contacto de emergencia</p>
|
||||
<p className="text-sm text-gray-600">{employee.emergencyContact || '-'}</p>
|
||||
<p className="text-sm text-gray-500">{employee.emergencyPhone || '-'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Tabs defaultTab="contracts">
|
||||
<TabList className="px-4 pt-4">
|
||||
<Tab value="contracts" icon={<FileText className="h-4 w-4" />}>
|
||||
Contratos ({contracts.length})
|
||||
</Tab>
|
||||
<Tab value="leaves" icon={<Calendar className="h-4 w-4" />}>
|
||||
Ausencias ({leaves.length})
|
||||
</Tab>
|
||||
<Tab value="subordinates" icon={<Users className="h-4 w-4" />}>
|
||||
Subordinados ({subordinates.length})
|
||||
</Tab>
|
||||
<Tab value="bank" icon={<CreditCard className="h-4 w-4" />}>
|
||||
Datos Bancarios
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanels className="px-4 pb-4">
|
||||
<TabPanel value="contracts">
|
||||
<DataTable
|
||||
data={contracts}
|
||||
columns={contractColumns}
|
||||
isLoading={loadingContracts}
|
||||
emptyMessage="No hay contratos registrados"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value="leaves">
|
||||
<DataTable
|
||||
data={leaves}
|
||||
columns={leaveColumns}
|
||||
isLoading={loadingLeaves}
|
||||
emptyMessage="No hay ausencias registradas"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value="subordinates">
|
||||
{subordinates.length === 0 ? (
|
||||
<p className="py-8 text-center text-gray-500">No tiene subordinados directos</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{subordinates.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 hover:bg-gray-50"
|
||||
onClick={() => navigate(`/hr/employees/${sub.id}`)}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{sub.firstName[0]}{sub.lastName[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{sub.fullName}</p>
|
||||
<p className="text-sm text-gray-500">{sub.jobPositionName || sub.employeeNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabPanel value="bank">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Banco</p>
|
||||
<p className="font-medium">{employee.bankName || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Numero de cuenta</p>
|
||||
<p className="font-medium">{employee.bankAccount || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">CLABE</p>
|
||||
<p className="font-medium">{employee.bankClabe || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showTerminateModal}
|
||||
onClose={() => setShowTerminateModal(false)}
|
||||
onConfirm={handleTerminate}
|
||||
title="Dar de baja empleado"
|
||||
message={`¿Dar de baja al empleado "${employee.fullName}"? Se registrara con fecha de hoy.`}
|
||||
variant="warning"
|
||||
confirmText="Dar de baja"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showReactivateModal}
|
||||
onClose={() => setShowReactivateModal(false)}
|
||||
onConfirm={handleReactivate}
|
||||
title="Reactivar empleado"
|
||||
message={`¿Reactivar al empleado "${employee.fullName}"?`}
|
||||
variant="success"
|
||||
confirmText="Reactivar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmployeeDetailPage;
|
||||
658
src/pages/hr/EmployeeFormPage.tsx
Normal file
658
src/pages/hr/EmployeeFormPage.tsx
Normal file
@ -0,0 +1,658 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Save,
|
||||
ArrowLeft,
|
||||
User,
|
||||
Building2,
|
||||
Phone,
|
||||
CreditCard,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardContent } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { Tabs, TabList, Tab, TabPanels, TabPanel } from '@components/organisms/Tabs';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { useEmployee, useDepartments, useJobPositions, useEmployees } from '@features/hr/hooks';
|
||||
import type { CreateEmployeeDto, UpdateEmployeeDto } from '@features/hr/types';
|
||||
import { GENDER_OPTIONS, MARITAL_STATUS_OPTIONS } from '@features/hr/types';
|
||||
|
||||
interface FormData {
|
||||
companyId: string;
|
||||
employeeNumber: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName: string;
|
||||
birthDate: string;
|
||||
gender: string;
|
||||
maritalStatus: string;
|
||||
nationality: string;
|
||||
identificationId: string;
|
||||
identificationType: string;
|
||||
socialSecurityNumber: string;
|
||||
taxId: string;
|
||||
email: string;
|
||||
workEmail: string;
|
||||
phone: string;
|
||||
workPhone: string;
|
||||
mobile: string;
|
||||
emergencyContact: string;
|
||||
emergencyPhone: string;
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
country: string;
|
||||
departmentId: string;
|
||||
jobPositionId: string;
|
||||
managerId: string;
|
||||
hireDate: string;
|
||||
bankName: string;
|
||||
bankAccount: string;
|
||||
bankClabe: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
companyId: '',
|
||||
employeeNumber: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
middleName: '',
|
||||
birthDate: '',
|
||||
gender: '',
|
||||
maritalStatus: '',
|
||||
nationality: 'Mexicana',
|
||||
identificationId: '',
|
||||
identificationType: 'INE',
|
||||
socialSecurityNumber: '',
|
||||
taxId: '',
|
||||
email: '',
|
||||
workEmail: '',
|
||||
phone: '',
|
||||
workPhone: '',
|
||||
mobile: '',
|
||||
emergencyContact: '',
|
||||
emergencyPhone: '',
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip: '',
|
||||
country: 'Mexico',
|
||||
departmentId: '',
|
||||
jobPositionId: '',
|
||||
managerId: '',
|
||||
hireDate: new Date().toISOString().split('T')[0] as string,
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
bankClabe: '',
|
||||
notes: '',
|
||||
};
|
||||
|
||||
export function EmployeeFormPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isEditing = !!id;
|
||||
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const { employee, isLoading: loadingEmployee, create, update, isSaving } = useEmployee({
|
||||
employeeId: id,
|
||||
autoLoad: isEditing,
|
||||
});
|
||||
|
||||
const { departments } = useDepartments({ autoLoad: true });
|
||||
const { positions } = useJobPositions({ autoLoad: true });
|
||||
const { employees } = useEmployees({
|
||||
initialFilters: { status: 'active' },
|
||||
autoLoad: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (employee && isEditing) {
|
||||
setFormData({
|
||||
companyId: employee.companyId || '',
|
||||
employeeNumber: employee.employeeNumber || '',
|
||||
firstName: employee.firstName || '',
|
||||
lastName: employee.lastName || '',
|
||||
middleName: employee.middleName || '',
|
||||
birthDate: employee.birthDate || '',
|
||||
gender: employee.gender || '',
|
||||
maritalStatus: employee.maritalStatus || '',
|
||||
nationality: employee.nationality || 'Mexicana',
|
||||
identificationId: employee.identificationId || '',
|
||||
identificationType: employee.identificationType || 'INE',
|
||||
socialSecurityNumber: employee.socialSecurityNumber || '',
|
||||
taxId: employee.taxId || '',
|
||||
email: employee.email || '',
|
||||
workEmail: employee.workEmail || '',
|
||||
phone: employee.phone || '',
|
||||
workPhone: employee.workPhone || '',
|
||||
mobile: employee.mobile || '',
|
||||
emergencyContact: employee.emergencyContact || '',
|
||||
emergencyPhone: employee.emergencyPhone || '',
|
||||
street: employee.street || '',
|
||||
city: employee.city || '',
|
||||
state: employee.state || '',
|
||||
zip: employee.zip || '',
|
||||
country: employee.country || 'Mexico',
|
||||
departmentId: employee.departmentId || '',
|
||||
jobPositionId: employee.jobPositionId || '',
|
||||
managerId: employee.managerId || '',
|
||||
hireDate: employee.hireDate || '',
|
||||
bankName: employee.bankName || '',
|
||||
bankAccount: employee.bankAccount || '',
|
||||
bankClabe: employee.bankClabe || '',
|
||||
notes: employee.notes || '',
|
||||
});
|
||||
}
|
||||
}, [employee, isEditing]);
|
||||
|
||||
const handleChange = (field: keyof FormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.employeeNumber.trim()) {
|
||||
newErrors.employeeNumber = 'El numero de empleado es requerido';
|
||||
}
|
||||
if (!formData.firstName.trim()) {
|
||||
newErrors.firstName = 'El nombre es requerido';
|
||||
}
|
||||
if (!formData.lastName.trim()) {
|
||||
newErrors.lastName = 'El apellido es requerido';
|
||||
}
|
||||
if (!formData.hireDate) {
|
||||
newErrors.hireDate = 'La fecha de ingreso es requerida';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitError(null);
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
const updateData: UpdateEmployeeDto = {
|
||||
firstName: formData.firstName || undefined,
|
||||
lastName: formData.lastName || undefined,
|
||||
middleName: formData.middleName || undefined,
|
||||
birthDate: formData.birthDate || undefined,
|
||||
gender: formData.gender || undefined,
|
||||
maritalStatus: formData.maritalStatus || undefined,
|
||||
nationality: formData.nationality || undefined,
|
||||
identificationId: formData.identificationId || undefined,
|
||||
identificationType: formData.identificationType || undefined,
|
||||
socialSecurityNumber: formData.socialSecurityNumber || undefined,
|
||||
taxId: formData.taxId || undefined,
|
||||
email: formData.email || undefined,
|
||||
workEmail: formData.workEmail || undefined,
|
||||
phone: formData.phone || undefined,
|
||||
workPhone: formData.workPhone || undefined,
|
||||
mobile: formData.mobile || undefined,
|
||||
emergencyContact: formData.emergencyContact || undefined,
|
||||
emergencyPhone: formData.emergencyPhone || undefined,
|
||||
street: formData.street || undefined,
|
||||
city: formData.city || undefined,
|
||||
state: formData.state || undefined,
|
||||
zip: formData.zip || undefined,
|
||||
country: formData.country || undefined,
|
||||
departmentId: formData.departmentId || undefined,
|
||||
jobPositionId: formData.jobPositionId || undefined,
|
||||
managerId: formData.managerId || undefined,
|
||||
bankName: formData.bankName || undefined,
|
||||
bankAccount: formData.bankAccount || undefined,
|
||||
bankClabe: formData.bankClabe || undefined,
|
||||
notes: formData.notes || undefined,
|
||||
};
|
||||
await update(updateData);
|
||||
} else {
|
||||
const createData: CreateEmployeeDto = {
|
||||
companyId: formData.companyId,
|
||||
employeeNumber: formData.employeeNumber,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
middleName: formData.middleName || undefined,
|
||||
birthDate: formData.birthDate || undefined,
|
||||
gender: formData.gender || undefined,
|
||||
maritalStatus: formData.maritalStatus || undefined,
|
||||
nationality: formData.nationality || undefined,
|
||||
identificationId: formData.identificationId || undefined,
|
||||
identificationType: formData.identificationType || undefined,
|
||||
socialSecurityNumber: formData.socialSecurityNumber || undefined,
|
||||
taxId: formData.taxId || undefined,
|
||||
email: formData.email || undefined,
|
||||
workEmail: formData.workEmail || undefined,
|
||||
phone: formData.phone || undefined,
|
||||
workPhone: formData.workPhone || undefined,
|
||||
mobile: formData.mobile || undefined,
|
||||
emergencyContact: formData.emergencyContact || undefined,
|
||||
emergencyPhone: formData.emergencyPhone || undefined,
|
||||
street: formData.street || undefined,
|
||||
city: formData.city || undefined,
|
||||
state: formData.state || undefined,
|
||||
zip: formData.zip || undefined,
|
||||
country: formData.country || undefined,
|
||||
departmentId: formData.departmentId || undefined,
|
||||
jobPositionId: formData.jobPositionId || undefined,
|
||||
managerId: formData.managerId || undefined,
|
||||
hireDate: formData.hireDate,
|
||||
bankName: formData.bankName || undefined,
|
||||
bankAccount: formData.bankAccount || undefined,
|
||||
bankClabe: formData.bankClabe || undefined,
|
||||
notes: formData.notes || undefined,
|
||||
};
|
||||
await create(createData);
|
||||
}
|
||||
navigate('/hr/employees');
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'Error al guardar empleado');
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingEmployee && isEditing) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Recursos Humanos', href: '/hr' },
|
||||
{ label: 'Empleados', href: '/hr/employees' },
|
||||
{ label: isEditing ? 'Editar empleado' : 'Nuevo empleado' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/hr/employees')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEditing ? 'Editar empleado' : 'Nuevo empleado'}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{isEditing ? 'Actualiza la informacion del empleado' : 'Registra un nuevo empleado en el sistema'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => navigate('/hr/employees')}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSaving}>
|
||||
{isSaving ? <Spinner size="sm" className="mr-2" /> : <Save className="mr-2 h-4 w-4" />}
|
||||
{isEditing ? 'Guardar cambios' : 'Crear empleado'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<Alert variant="danger">
|
||||
{submitError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Tabs defaultTab="personal">
|
||||
<TabList className="px-4 pt-4">
|
||||
<Tab value="personal" icon={<User className="h-4 w-4" />}>Personal</Tab>
|
||||
<Tab value="work" icon={<Building2 className="h-4 w-4" />}>Laboral</Tab>
|
||||
<Tab value="contact" icon={<Phone className="h-4 w-4" />}>Contacto</Tab>
|
||||
<Tab value="bank" icon={<CreditCard className="h-4 w-4" />}>Bancario</Tab>
|
||||
</TabList>
|
||||
<TabPanels className="px-4 pb-4">
|
||||
<TabPanel value="personal">
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Numero de empleado *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.employeeNumber}
|
||||
onChange={(e) => handleChange('employeeNumber', e.target.value)}
|
||||
disabled={isEditing}
|
||||
className={`mt-1 w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.employeeNumber ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
} ${isEditing ? 'bg-gray-100' : ''}`}
|
||||
/>
|
||||
{errors.employeeNumber && <p className="mt-1 text-sm text-red-500">{errors.employeeNumber}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Nombre *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => handleChange('firstName', e.target.value)}
|
||||
className={`mt-1 w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.firstName ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
/>
|
||||
{errors.firstName && <p className="mt-1 text-sm text-red-500">{errors.firstName}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Segundo nombre</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.middleName}
|
||||
onChange={(e) => handleChange('middleName', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Apellido *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => handleChange('lastName', e.target.value)}
|
||||
className={`mt-1 w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.lastName ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
/>
|
||||
{errors.lastName && <p className="mt-1 text-sm text-red-500">{errors.lastName}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Fecha de nacimiento</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.birthDate}
|
||||
onChange={(e) => handleChange('birthDate', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Genero</label>
|
||||
<select
|
||||
value={formData.gender}
|
||||
onChange={(e) => handleChange('gender', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Estado civil</label>
|
||||
<select
|
||||
value={formData.maritalStatus}
|
||||
onChange={(e) => handleChange('maritalStatus', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{MARITAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">RFC</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.taxId}
|
||||
onChange={(e) => handleChange('taxId', e.target.value.toUpperCase())}
|
||||
maxLength={13}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 uppercase focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">NSS (IMSS)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.socialSecurityNumber}
|
||||
onChange={(e) => handleChange('socialSecurityNumber', e.target.value)}
|
||||
maxLength={11}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="work">
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Fecha de ingreso *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.hireDate}
|
||||
onChange={(e) => handleChange('hireDate', e.target.value)}
|
||||
disabled={isEditing}
|
||||
className={`mt-1 w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.hireDate ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
} ${isEditing ? 'bg-gray-100' : ''}`}
|
||||
/>
|
||||
{errors.hireDate && <p className="mt-1 text-sm text-red-500">{errors.hireDate}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Departamento</label>
|
||||
<select
|
||||
value={formData.departmentId}
|
||||
onChange={(e) => handleChange('departmentId', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{departments.map((dept) => (
|
||||
<option key={dept.id} value={dept.id}>{dept.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Puesto</label>
|
||||
<select
|
||||
value={formData.jobPositionId}
|
||||
onChange={(e) => handleChange('jobPositionId', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{positions.map((pos) => (
|
||||
<option key={pos.id} value={pos.id}>{pos.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Reporta a</label>
|
||||
<select
|
||||
value={formData.managerId}
|
||||
onChange={(e) => handleChange('managerId', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{employees.filter(e => e.id !== id).map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Notas</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => handleChange('notes', e.target.value)}
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="contact">
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Email personal</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Email laboral</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.workEmail}
|
||||
onChange={(e) => handleChange('workEmail', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Telefono personal</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Telefono laboral</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.workPhone}
|
||||
onChange={(e) => handleChange('workPhone', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Celular</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.mobile}
|
||||
onChange={(e) => handleChange('mobile', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<h3 className="mb-4 text-sm font-semibold text-gray-900">Direccion</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Calle y numero</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.street}
|
||||
onChange={(e) => handleChange('street', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Ciudad</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleChange('city', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Estado</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.state}
|
||||
onChange={(e) => handleChange('state', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Codigo postal</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.zip}
|
||||
onChange={(e) => handleChange('zip', e.target.value)}
|
||||
maxLength={5}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<h3 className="mb-4 text-sm font-semibold text-gray-900">Contacto de emergencia</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Nombre</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.emergencyContact}
|
||||
onChange={(e) => handleChange('emergencyContact', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Telefono</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.emergencyPhone}
|
||||
onChange={(e) => handleChange('emergencyPhone', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="bank">
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Banco</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.bankName}
|
||||
onChange={(e) => handleChange('bankName', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Numero de cuenta</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.bankAccount}
|
||||
onChange={(e) => handleChange('bankAccount', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">CLABE interbancaria</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.bankClabe}
|
||||
onChange={(e) => handleChange('bankClabe', e.target.value)}
|
||||
maxLength={18}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmployeeFormPage;
|
||||
432
src/pages/hr/EmployeesPage.tsx
Normal file
432
src/pages/hr/EmployeesPage.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Search,
|
||||
UserCheck,
|
||||
UserX,
|
||||
UserMinus,
|
||||
RefreshCw,
|
||||
Building2,
|
||||
Briefcase,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useEmployees, useDepartments } from '@features/hr/hooks';
|
||||
import type { Employee, EmployeeStatus } from '@features/hr/types';
|
||||
import { EMPLOYEE_STATUS_LABELS, EMPLOYEE_STATUS_COLORS } from '@features/hr/types';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
export function EmployeesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<EmployeeStatus | ''>('');
|
||||
const [selectedDepartment, setSelectedDepartment] = useState<string>('');
|
||||
const [employeeToDelete, setEmployeeToDelete] = useState<Employee | null>(null);
|
||||
const [employeeToTerminate, setEmployeeToTerminate] = useState<Employee | null>(null);
|
||||
const [employeeToReactivate, setEmployeeToReactivate] = useState<Employee | null>(null);
|
||||
|
||||
const {
|
||||
employees,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
setFilters,
|
||||
refresh,
|
||||
remove,
|
||||
terminate,
|
||||
reactivate,
|
||||
} = useEmployees({
|
||||
initialFilters: {
|
||||
search: searchTerm,
|
||||
status: selectedStatus || undefined,
|
||||
departmentId: selectedDepartment || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { departments } = useDepartments({ autoLoad: true });
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setFilters({ search: value });
|
||||
};
|
||||
|
||||
const handleStatusFilter = (status: EmployeeStatus | '') => {
|
||||
setSelectedStatus(status);
|
||||
setFilters({ status: status || undefined });
|
||||
};
|
||||
|
||||
const handleDepartmentFilter = (departmentId: string) => {
|
||||
setSelectedDepartment(departmentId);
|
||||
setFilters({ departmentId: departmentId || undefined });
|
||||
};
|
||||
|
||||
const columns: Column<Employee>[] = [
|
||||
{
|
||||
key: 'employee',
|
||||
header: 'Empleado',
|
||||
render: (employee) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||
{employee.photoUrl ? (
|
||||
<img src={employee.photoUrl} alt={employee.fullName} className="h-10 w-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{employee.firstName[0]}{employee.lastName[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{employee.fullName}</div>
|
||||
<div className="text-sm text-gray-500">{employee.employeeNumber}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'department',
|
||||
header: 'Departamento',
|
||||
render: (employee) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">{employee.departmentName || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'position',
|
||||
header: 'Puesto',
|
||||
render: (employee) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Briefcase className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">{employee.jobPositionName || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Estado',
|
||||
render: (employee) => (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${EMPLOYEE_STATUS_COLORS[employee.status]}`}>
|
||||
{employee.status === 'active' ? <UserCheck className="h-3 w-3" /> : <UserX className="h-3 w-3" />}
|
||||
{EMPLOYEE_STATUS_LABELS[employee.status]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hireDate',
|
||||
header: 'Fecha ingreso',
|
||||
render: (employee) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{formatDate(employee.hireDate, 'short')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
header: 'Contacto',
|
||||
render: (employee) => (
|
||||
<div className="text-sm">
|
||||
{employee.workEmail && <div className="text-gray-600">{employee.workEmail}</div>}
|
||||
{employee.workPhone && <div className="text-gray-500">{employee.workPhone}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (employee) => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/hr/employees/${employee.id}`),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit2 className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/hr/employees/${employee.id}/edit`),
|
||||
},
|
||||
];
|
||||
|
||||
if (employee.status === 'active') {
|
||||
items.push({
|
||||
key: 'terminate',
|
||||
label: 'Dar de baja',
|
||||
icon: <UserMinus className="h-4 w-4" />,
|
||||
onClick: () => setEmployeeToTerminate(employee),
|
||||
});
|
||||
} else if (employee.status === 'terminated' || employee.status === 'inactive') {
|
||||
items.push({
|
||||
key: 'reactivate',
|
||||
label: 'Reactivar',
|
||||
icon: <UserCheck className="h-4 w-4" />,
|
||||
onClick: () => setEmployeeToReactivate(employee),
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: 'Eliminar',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setEmployeeToDelete(employee),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={items}
|
||||
align="right"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleDeleteEmployee = async () => {
|
||||
if (employeeToDelete) {
|
||||
await remove(employeeToDelete.id);
|
||||
setEmployeeToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminateEmployee = async () => {
|
||||
if (employeeToTerminate) {
|
||||
const today = new Date().toISOString().split('T')[0] as string;
|
||||
await terminate(employeeToTerminate.id, today);
|
||||
setEmployeeToTerminate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivateEmployee = async () => {
|
||||
if (employeeToReactivate) {
|
||||
await reactivate(employeeToReactivate.id);
|
||||
setEmployeeToReactivate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const activeCount = employees.filter(e => e.status === 'active').length;
|
||||
const onLeaveCount = employees.filter(e => e.status === 'on_leave').length;
|
||||
const terminatedCount = employees.filter(e => e.status === 'terminated').length;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Recursos Humanos', href: '/hr' },
|
||||
{ label: 'Empleados' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Empleados</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona el directorio de empleados de tu empresa
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/hr/employees/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo empleado
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Total</div>
|
||||
<div className="text-xl font-bold text-blue-600">{total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleStatusFilter('active')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||
<UserCheck className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Activos</div>
|
||||
<div className="text-xl font-bold text-green-600">{activeCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleStatusFilter('on_leave')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100">
|
||||
<UserX className="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Con permiso</div>
|
||||
<div className="text-xl font-bold text-yellow-600">{onLeaveCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleStatusFilter('terminated')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
|
||||
<UserMinus className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Dados de baja</div>
|
||||
<div className="text-xl font-bold text-red-600">{terminatedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Directorio de Empleados</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar empleados..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedDepartment}
|
||||
onChange={(e) => handleDepartmentFilter(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los departamentos</option>
|
||||
{departments.map((dept) => (
|
||||
<option key={dept.id} value={dept.id}>{dept.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => handleStatusFilter(e.target.value as EmployeeStatus | '')}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{Object.entries(EMPLOYEE_STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{(searchTerm || selectedStatus || selectedDepartment) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedStatus('');
|
||||
setSelectedDepartment('');
|
||||
setFilters({});
|
||||
}}
|
||||
>
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{employees.length === 0 && !isLoading ? (
|
||||
<NoDataEmptyState entityName="empleados" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={employees}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
limit: 20,
|
||||
onPageChange: (p) => setFilters({ page: p }),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!employeeToDelete}
|
||||
onClose={() => setEmployeeToDelete(null)}
|
||||
onConfirm={handleDeleteEmployee}
|
||||
title="Eliminar empleado"
|
||||
message={`¿Eliminar al empleado "${employeeToDelete?.fullName}"? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!employeeToTerminate}
|
||||
onClose={() => setEmployeeToTerminate(null)}
|
||||
onConfirm={handleTerminateEmployee}
|
||||
title="Dar de baja empleado"
|
||||
message={`¿Dar de baja al empleado "${employeeToTerminate?.fullName}"? Se registrara con fecha de hoy.`}
|
||||
variant="warning"
|
||||
confirmText="Dar de baja"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!employeeToReactivate}
|
||||
onClose={() => setEmployeeToReactivate(null)}
|
||||
onConfirm={handleReactivateEmployee}
|
||||
title="Reactivar empleado"
|
||||
message={`¿Reactivar al empleado "${employeeToReactivate?.fullName}"?`}
|
||||
variant="success"
|
||||
confirmText="Reactivar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmployeesPage;
|
||||
402
src/pages/hr/LeaveRequestPage.tsx
Normal file
402
src/pages/hr/LeaveRequestPage.tsx
Normal file
@ -0,0 +1,402 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Calendar,
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { useLeaves, useLeaveTypes, useEmployees } from '@features/hr/hooks';
|
||||
import type { CreateLeaveDto } from '@features/hr/types';
|
||||
|
||||
interface FormData {
|
||||
companyId: string;
|
||||
employeeId: string;
|
||||
leaveTypeId: string;
|
||||
name: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
companyId: '',
|
||||
employeeId: '',
|
||||
leaveTypeId: '',
|
||||
name: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
export function LeaveRequestPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const preselectedEmployeeId = searchParams.get('employeeId');
|
||||
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [daysCount, setDaysCount] = useState<number>(0);
|
||||
|
||||
const { create, submit } = useLeaves({ autoLoad: false });
|
||||
const { leaveTypes, isLoading: loadingTypes } = useLeaveTypes();
|
||||
const { employees, isLoading: loadingEmployees } = useEmployees({
|
||||
initialFilters: { status: 'active' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (preselectedEmployeeId) {
|
||||
setFormData(prev => ({ ...prev, employeeId: preselectedEmployeeId }));
|
||||
}
|
||||
}, [preselectedEmployeeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.dateFrom && formData.dateTo) {
|
||||
const from = new Date(formData.dateFrom);
|
||||
const to = new Date(formData.dateTo);
|
||||
if (to >= from) {
|
||||
const diffTime = Math.abs(to.getTime() - from.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
setDaysCount(diffDays);
|
||||
} else {
|
||||
setDaysCount(0);
|
||||
}
|
||||
} else {
|
||||
setDaysCount(0);
|
||||
}
|
||||
}, [formData.dateFrom, formData.dateTo]);
|
||||
|
||||
const handleChange = (field: keyof FormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.employeeId) {
|
||||
newErrors.employeeId = 'Selecciona un empleado';
|
||||
}
|
||||
if (!formData.leaveTypeId) {
|
||||
newErrors.leaveTypeId = 'Selecciona el tipo de ausencia';
|
||||
}
|
||||
if (!formData.dateFrom) {
|
||||
newErrors.dateFrom = 'La fecha de inicio es requerida';
|
||||
}
|
||||
if (!formData.dateTo) {
|
||||
newErrors.dateTo = 'La fecha de fin es requerida';
|
||||
}
|
||||
if (formData.dateFrom && formData.dateTo) {
|
||||
const from = new Date(formData.dateFrom);
|
||||
const to = new Date(formData.dateTo);
|
||||
if (to < from) {
|
||||
newErrors.dateTo = 'La fecha de fin debe ser posterior a la de inicio';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const createData: CreateLeaveDto = {
|
||||
companyId: formData.companyId,
|
||||
employeeId: formData.employeeId,
|
||||
leaveTypeId: formData.leaveTypeId,
|
||||
name: formData.name || undefined,
|
||||
dateFrom: formData.dateFrom,
|
||||
dateTo: formData.dateTo,
|
||||
description: formData.description || undefined,
|
||||
};
|
||||
await create(createData);
|
||||
navigate('/hr/leaves');
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'Error al guardar la solicitud');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const createData: CreateLeaveDto = {
|
||||
companyId: formData.companyId,
|
||||
employeeId: formData.employeeId,
|
||||
leaveTypeId: formData.leaveTypeId,
|
||||
name: formData.name || undefined,
|
||||
dateFrom: formData.dateFrom,
|
||||
dateTo: formData.dateTo,
|
||||
description: formData.description || undefined,
|
||||
};
|
||||
const leave = await create(createData);
|
||||
await submit(leave.id);
|
||||
navigate('/hr/leaves');
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'Error al enviar la solicitud');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedLeaveType = leaveTypes.find(t => t.id === formData.leaveTypeId);
|
||||
const selectedEmployee = employees.find(e => e.id === formData.employeeId);
|
||||
|
||||
if (loadingTypes || loadingEmployees) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Recursos Humanos', href: '/hr' },
|
||||
{ label: 'Ausencias', href: '/hr/leaves' },
|
||||
{ label: 'Nueva solicitud' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/hr/leaves')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Nueva Solicitud de Ausencia</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Completa el formulario para solicitar un permiso o vacaciones
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => navigate('/hr/leaves')}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleSaveDraft} disabled={isSaving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Guardar borrador
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSaving}>
|
||||
{isSaving ? <Spinner size="sm" className="mr-2" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
Enviar solicitud
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<Alert variant="danger">
|
||||
{submitError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Datos de la Solicitud
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Empleado *</label>
|
||||
<select
|
||||
value={formData.employeeId}
|
||||
onChange={(e) => handleChange('employeeId', e.target.value)}
|
||||
className={`mt-1 w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.employeeId ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
>
|
||||
<option value="">Seleccionar empleado</option>
|
||||
{employees.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>
|
||||
{emp.fullName} ({emp.employeeNumber})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.employeeId && <p className="mt-1 text-sm text-red-500">{errors.employeeId}</p>}
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Tipo de ausencia *</label>
|
||||
<select
|
||||
value={formData.leaveTypeId}
|
||||
onChange={(e) => handleChange('leaveTypeId', e.target.value)}
|
||||
className={`mt-1 w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.leaveTypeId ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
>
|
||||
<option value="">Seleccionar tipo</option>
|
||||
{leaveTypes.map((type) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.name} {type.maxDays && `(max ${type.maxDays} dias)`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.leaveTypeId && <p className="mt-1 text-sm text-red-500">{errors.leaveTypeId}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Fecha de inicio *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.dateFrom}
|
||||
onChange={(e) => handleChange('dateFrom', e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
className={`mt-1 w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.dateFrom ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
/>
|
||||
{errors.dateFrom && <p className="mt-1 text-sm text-red-500">{errors.dateFrom}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Fecha de fin *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.dateTo}
|
||||
onChange={(e) => handleChange('dateTo', e.target.value)}
|
||||
min={formData.dateFrom || new Date().toISOString().split('T')[0]}
|
||||
className={`mt-1 w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.dateTo ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
/>
|
||||
{errors.dateTo && <p className="mt-1 text-sm text-red-500">{errors.dateTo}</p>}
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Nombre o referencia</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Ej: Vacaciones de verano"
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Motivo o descripcion</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Proporciona detalles adicionales sobre tu solicitud..."
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resumen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedEmployee && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Empleado</p>
|
||||
<p className="font-medium text-gray-900">{selectedEmployee.fullName}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedLeaveType && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Tipo de ausencia</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedLeaveType.color && (
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: selectedLeaveType.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium text-gray-900">{selectedLeaveType.name}</span>
|
||||
</div>
|
||||
{selectedLeaveType.maxDays && (
|
||||
<p className="text-sm text-gray-500">Maximo {selectedLeaveType.maxDays} dias</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{formData.dateFrom && formData.dateTo && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Periodo</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{new Date(formData.dateFrom).toLocaleDateString('es-MX', { dateStyle: 'medium' })}
|
||||
{' - '}
|
||||
{new Date(formData.dateTo).toLocaleDateString('es-MX', { dateStyle: 'medium' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm text-gray-500">Dias solicitados</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{daysCount}</p>
|
||||
</div>
|
||||
{selectedLeaveType && selectedLeaveType.maxDays && daysCount > selectedLeaveType.maxDays && (
|
||||
<Alert variant="warning">
|
||||
Excede el maximo permitido de {selectedLeaveType.maxDays} dias
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedLeaveType && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informacion del tipo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Requiere aprobacion</span>
|
||||
<span className={`text-sm font-medium ${selectedLeaveType.requiresApproval ? 'text-yellow-600' : 'text-green-600'}`}>
|
||||
{selectedLeaveType.requiresApproval ? 'Si' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Con goce de sueldo</span>
|
||||
<span className={`text-sm font-medium ${selectedLeaveType.isPaid ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{selectedLeaveType.isPaid ? 'Si' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
{selectedLeaveType.maxDays && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Dias maximos</span>
|
||||
<span className="text-sm font-medium text-gray-900">{selectedLeaveType.maxDays}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LeaveRequestPage;
|
||||
582
src/pages/hr/LeavesPage.tsx
Normal file
582
src/pages/hr/LeavesPage.tsx
Normal file
@ -0,0 +1,582 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Search,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { Modal, ConfirmModal } from '@components/organisms/Modal';
|
||||
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useLeaves, useLeaveTypes, useEmployees } from '@features/hr/hooks';
|
||||
import type { Leave, LeaveStatus, CreateLeaveDto } from '@features/hr/types';
|
||||
import { LEAVE_STATUS_LABELS, LEAVE_STATUS_COLORS } from '@features/hr/types';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
export function LeavesPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<LeaveStatus | ''>('');
|
||||
const [selectedType, setSelectedType] = useState<string>('');
|
||||
const [leaveToDelete, setLeaveToDelete] = useState<Leave | null>(null);
|
||||
const [leaveToApprove, setLeaveToApprove] = useState<Leave | null>(null);
|
||||
const [leaveToReject, setLeaveToReject] = useState<Leave | null>(null);
|
||||
const [leaveToCancel, setLeaveToCancel] = useState<Leave | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [showNewModal, setShowNewModal] = useState(false);
|
||||
|
||||
const [newLeave, setNewLeave] = useState<Partial<CreateLeaveDto>>({});
|
||||
|
||||
const {
|
||||
leaves,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
setFilters,
|
||||
refresh,
|
||||
create,
|
||||
remove,
|
||||
submit,
|
||||
approve,
|
||||
reject,
|
||||
cancel,
|
||||
} = useLeaves({
|
||||
initialFilters: {
|
||||
search: searchTerm,
|
||||
status: selectedStatus || undefined,
|
||||
leaveTypeId: selectedType || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { leaveTypes } = useLeaveTypes();
|
||||
const { employees } = useEmployees({ initialFilters: { status: 'active' } });
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setFilters({ search: value });
|
||||
};
|
||||
|
||||
const handleStatusFilter = (status: LeaveStatus | '') => {
|
||||
setSelectedStatus(status);
|
||||
setFilters({ status: status || undefined });
|
||||
};
|
||||
|
||||
const handleTypeFilter = (typeId: string) => {
|
||||
setSelectedType(typeId);
|
||||
setFilters({ leaveTypeId: typeId || undefined });
|
||||
};
|
||||
|
||||
const columns: Column<Leave>[] = [
|
||||
{
|
||||
key: 'employee',
|
||||
header: 'Empleado',
|
||||
render: (leave) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{leave.employeeName}</div>
|
||||
<div className="text-sm text-gray-500">{leave.employeeNumber}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Tipo',
|
||||
render: (leave) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{leave.leaveTypeColor && (
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: leave.leaveTypeColor }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-gray-900">{leave.leaveTypeName}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
header: 'Periodo',
|
||||
render: (leave) => (
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-900">
|
||||
{formatDate(leave.dateFrom, 'short')} - {formatDate(leave.dateTo, 'short')}
|
||||
</div>
|
||||
<div className="text-gray-500">{leave.daysRequested} dias</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Estado',
|
||||
render: (leave) => (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${LEAVE_STATUS_COLORS[leave.status]}`}>
|
||||
{LEAVE_STATUS_LABELS[leave.status]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'submitted',
|
||||
header: 'Enviada',
|
||||
render: (leave) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{leave.submittedAt ? formatDate(leave.submittedAt, 'short') : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'approver',
|
||||
header: 'Aprobador',
|
||||
render: (leave) => (
|
||||
<div className="text-sm">
|
||||
{leave.status === 'approved' && (
|
||||
<>
|
||||
<div className="text-green-600">{leave.approverName}</div>
|
||||
<div className="text-gray-500">{leave.approvedAt ? formatDate(leave.approvedAt, 'short') : ''}</div>
|
||||
</>
|
||||
)}
|
||||
{leave.status === 'rejected' && (
|
||||
<div className="text-red-600">{leave.approverName}</div>
|
||||
)}
|
||||
{leave.status === 'submitted' && (
|
||||
<div className="text-blue-600">Pendiente</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (leave) => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
|
||||
if (leave.status === 'draft') {
|
||||
items.push({
|
||||
key: 'submit',
|
||||
label: 'Enviar solicitud',
|
||||
icon: <Send className="h-4 w-4" />,
|
||||
onClick: () => submit(leave.id),
|
||||
});
|
||||
items.push({
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit2 className="h-4 w-4" />,
|
||||
onClick: () => {},
|
||||
});
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: 'Eliminar',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setLeaveToDelete(leave),
|
||||
});
|
||||
}
|
||||
|
||||
if (leave.status === 'submitted') {
|
||||
items.push({
|
||||
key: 'approve',
|
||||
label: 'Aprobar',
|
||||
icon: <CheckCircle className="h-4 w-4" />,
|
||||
onClick: () => setLeaveToApprove(leave),
|
||||
});
|
||||
items.push({
|
||||
key: 'reject',
|
||||
label: 'Rechazar',
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setLeaveToReject(leave),
|
||||
});
|
||||
}
|
||||
|
||||
if (leave.status === 'approved' || leave.status === 'submitted') {
|
||||
items.push({
|
||||
key: 'cancel',
|
||||
label: 'Cancelar',
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setLeaveToCancel(leave),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={items}
|
||||
align="right"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleDeleteLeave = async () => {
|
||||
if (leaveToDelete) {
|
||||
await remove(leaveToDelete.id);
|
||||
setLeaveToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveLeave = async () => {
|
||||
if (leaveToApprove) {
|
||||
await approve(leaveToApprove.id);
|
||||
setLeaveToApprove(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectLeave = async () => {
|
||||
if (leaveToReject && rejectReason) {
|
||||
await reject(leaveToReject.id, rejectReason);
|
||||
setLeaveToReject(null);
|
||||
setRejectReason('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelLeave = async () => {
|
||||
if (leaveToCancel) {
|
||||
await cancel(leaveToCancel.id);
|
||||
setLeaveToCancel(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateLeave = async () => {
|
||||
if (!newLeave.employeeId || !newLeave.leaveTypeId || !newLeave.dateFrom || !newLeave.dateTo) {
|
||||
return;
|
||||
}
|
||||
await create(newLeave as CreateLeaveDto);
|
||||
setShowNewModal(false);
|
||||
setNewLeave({});
|
||||
};
|
||||
|
||||
const pendingCount = leaves.filter(l => l.status === 'submitted').length;
|
||||
const approvedCount = leaves.filter(l => l.status === 'approved').length;
|
||||
const rejectedCount = leaves.filter(l => l.status === 'rejected').length;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Recursos Humanos', href: '/hr' },
|
||||
{ label: 'Ausencias' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Solicitudes de Ausencia</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona las solicitudes de vacaciones y permisos
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button onClick={() => setShowNewModal(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nueva solicitud
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||
<Calendar className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Total</div>
|
||||
<div className="text-xl font-bold text-blue-600">{total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleStatusFilter('submitted')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100">
|
||||
<Clock className="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Pendientes</div>
|
||||
<div className="text-xl font-bold text-yellow-600">{pendingCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleStatusFilter('approved')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Aprobadas</div>
|
||||
<div className="text-xl font-bold text-green-600">{approvedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleStatusFilter('rejected')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Rechazadas</div>
|
||||
<div className="text-xl font-bold text-red-600">{rejectedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Solicitudes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar solicitudes..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => handleTypeFilter(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
{leaveTypes.map((type) => (
|
||||
<option key={type.id} value={type.id}>{type.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => handleStatusFilter(e.target.value as LeaveStatus | '')}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{Object.entries(LEAVE_STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{(searchTerm || selectedStatus || selectedType) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedStatus('');
|
||||
setSelectedType('');
|
||||
setFilters({});
|
||||
}}
|
||||
>
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{leaves.length === 0 && !isLoading ? (
|
||||
<NoDataEmptyState entityName="solicitudes" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={leaves}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
limit: 20,
|
||||
onPageChange: (p) => setFilters({ page: p }),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={showNewModal}
|
||||
onClose={() => setShowNewModal(false)}
|
||||
title="Nueva Solicitud de Ausencia"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Empleado *</label>
|
||||
<select
|
||||
value={newLeave.employeeId || ''}
|
||||
onChange={(e) => setNewLeave({ ...newLeave, employeeId: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar empleado</option>
|
||||
{employees.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Tipo de ausencia *</label>
|
||||
<select
|
||||
value={newLeave.leaveTypeId || ''}
|
||||
onChange={(e) => setNewLeave({ ...newLeave, leaveTypeId: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccionar tipo</option>
|
||||
{leaveTypes.map((type) => (
|
||||
<option key={type.id} value={type.id}>{type.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Fecha inicio *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newLeave.dateFrom || ''}
|
||||
onChange={(e) => setNewLeave({ ...newLeave, dateFrom: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Fecha fin *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newLeave.dateTo || ''}
|
||||
onChange={(e) => setNewLeave({ ...newLeave, dateTo: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Descripcion</label>
|
||||
<textarea
|
||||
value={newLeave.description || ''}
|
||||
onChange={(e) => setNewLeave({ ...newLeave, description: e.target.value })}
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={() => setShowNewModal(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateLeave}
|
||||
disabled={!newLeave.employeeId || !newLeave.leaveTypeId || !newLeave.dateFrom || !newLeave.dateTo}
|
||||
>
|
||||
Crear solicitud
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!leaveToDelete}
|
||||
onClose={() => setLeaveToDelete(null)}
|
||||
onConfirm={handleDeleteLeave}
|
||||
title="Eliminar solicitud"
|
||||
message="¿Eliminar esta solicitud de ausencia? Esta accion no se puede deshacer."
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!leaveToApprove}
|
||||
onClose={() => setLeaveToApprove(null)}
|
||||
onConfirm={handleApproveLeave}
|
||||
title="Aprobar solicitud"
|
||||
message={`¿Aprobar la solicitud de ausencia de ${leaveToApprove?.employeeName}?`}
|
||||
variant="success"
|
||||
confirmText="Aprobar"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={!!leaveToReject}
|
||||
onClose={() => { setLeaveToReject(null); setRejectReason(''); }}
|
||||
title="Rechazar solicitud"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
Proporciona una razon para rechazar la solicitud de {leaveToReject?.employeeName}.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Razon del rechazo *</label>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="Escribe la razon..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={() => { setLeaveToReject(null); setRejectReason(''); }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleRejectLeave} disabled={!rejectReason.trim()}>
|
||||
Rechazar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!leaveToCancel}
|
||||
onClose={() => setLeaveToCancel(null)}
|
||||
onConfirm={handleCancelLeave}
|
||||
title="Cancelar solicitud"
|
||||
message="¿Cancelar esta solicitud de ausencia?"
|
||||
variant="warning"
|
||||
confirmText="Cancelar solicitud"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LeavesPage;
|
||||
7
src/pages/hr/index.ts
Normal file
7
src/pages/hr/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { EmployeesPage } from './EmployeesPage';
|
||||
export { EmployeeDetailPage } from './EmployeeDetailPage';
|
||||
export { EmployeeFormPage } from './EmployeeFormPage';
|
||||
export { DepartmentsPage } from './DepartmentsPage';
|
||||
export { ContractsPage } from './ContractsPage';
|
||||
export { LeavesPage } from './LeavesPage';
|
||||
export { LeaveRequestPage } from './LeaveRequestPage';
|
||||
Loading…
Reference in New Issue
Block a user