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:
Adrian Flores Cortes 2026-01-26 16:34:03 -06:00
parent 14b84c61e2
commit 1363fbd5f6
21 changed files with 5649 additions and 22 deletions

31
package-lock.json generated
View File

@ -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",

View 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;
},
};

View File

@ -0,0 +1 @@
export * from './hr.api';

View 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';

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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
View File

@ -0,0 +1,10 @@
// HR Feature - Barrel Export
// Types
export * from './types';
// API
export * from './api';
// Hooks
export * from './hooks';

View 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' },
];

View File

@ -0,0 +1 @@
export * from './hr.types';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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';