From 1363fbd5f62fdc4479331c59ac94c569fcfdea7b Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Mon, 26 Jan 2026 16:34:03 -0600 Subject: [PATCH] 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 --- package-lock.json | 31 +- src/features/hr/api/hr.api.ts | 504 ++++++++++++++++++ src/features/hr/api/index.ts | 1 + src/features/hr/hooks/index.ts | 6 + src/features/hr/hooks/useContracts.ts | 124 +++++ src/features/hr/hooks/useDepartments.ts | 180 +++++++ src/features/hr/hooks/useEmployee.ts | 179 +++++++ src/features/hr/hooks/useEmployees.ts | 122 +++++ src/features/hr/hooks/useLeaveTypes.ts | 76 +++ src/features/hr/hooks/useLeaves.ts | 132 +++++ src/features/hr/index.ts | 10 + src/features/hr/types/hr.types.ts | 561 ++++++++++++++++++++ src/features/hr/types/index.ts | 1 + src/pages/hr/ContractsPage.tsx | 611 ++++++++++++++++++++++ src/pages/hr/DepartmentsPage.tsx | 570 ++++++++++++++++++++ src/pages/hr/EmployeeDetailPage.tsx | 482 +++++++++++++++++ src/pages/hr/EmployeeFormPage.tsx | 658 ++++++++++++++++++++++++ src/pages/hr/EmployeesPage.tsx | 432 ++++++++++++++++ src/pages/hr/LeaveRequestPage.tsx | 402 +++++++++++++++ src/pages/hr/LeavesPage.tsx | 582 +++++++++++++++++++++ src/pages/hr/index.ts | 7 + 21 files changed, 5649 insertions(+), 22 deletions(-) create mode 100644 src/features/hr/api/hr.api.ts create mode 100644 src/features/hr/api/index.ts create mode 100644 src/features/hr/hooks/index.ts create mode 100644 src/features/hr/hooks/useContracts.ts create mode 100644 src/features/hr/hooks/useDepartments.ts create mode 100644 src/features/hr/hooks/useEmployee.ts create mode 100644 src/features/hr/hooks/useEmployees.ts create mode 100644 src/features/hr/hooks/useLeaveTypes.ts create mode 100644 src/features/hr/hooks/useLeaves.ts create mode 100644 src/features/hr/index.ts create mode 100644 src/features/hr/types/hr.types.ts create mode 100644 src/features/hr/types/index.ts create mode 100644 src/pages/hr/ContractsPage.tsx create mode 100644 src/pages/hr/DepartmentsPage.tsx create mode 100644 src/pages/hr/EmployeeDetailPage.tsx create mode 100644 src/pages/hr/EmployeeFormPage.tsx create mode 100644 src/pages/hr/EmployeesPage.tsx create mode 100644 src/pages/hr/LeaveRequestPage.tsx create mode 100644 src/pages/hr/LeavesPage.tsx create mode 100644 src/pages/hr/index.ts diff --git a/package-lock.json b/package-lock.json index c2da76d..2c8ce11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/features/hr/api/hr.api.ts b/src/features/hr/api/hr.api.ts new file mode 100644 index 0000000..6f5b67e --- /dev/null +++ b/src/features/hr/api/hr.api.ts @@ -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 => { + 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 => { + const response = await api.get<{ success: boolean; data: Employee }>(`${HR_BASE}/employees/${id}`); + return response.data.data; + }, + + create: async (data: CreateEmployeeDto): Promise => { + 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 => { + const payload: Record = {}; + 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 => { + await api.delete(`${HR_BASE}/employees/${id}`); + }, + + terminate: async (id: string, terminationDate: string): Promise => { + 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 => { + const response = await api.post<{ success: boolean; data: Employee }>(`${HR_BASE}/employees/${id}/reactivate`); + return response.data.data; + }, + + getSubordinates: async (id: string): Promise => { + 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 => { + 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 => { + const response = await api.get<{ success: boolean; data: Department }>(`${HR_BASE}/departments/${id}`); + return response.data.data; + }, + + create: async (data: CreateDepartmentDto): Promise => { + 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 => { + const payload: Record = {}; + 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 => { + await api.delete(`${HR_BASE}/departments/${id}`); + }, +}; + +// ============================================================================ +// Job Positions API +// ============================================================================ + +export const jobPositionsApi = { + getAll: async (includeInactive = false): Promise => { + 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 => { + 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 => { + const payload: Record = {}; + 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 => { + await api.delete(`${HR_BASE}/positions/${id}`); + }, +}; + +// ============================================================================ +// Contracts API +// ============================================================================ + +export const contractsApi = { + getAll: async (filters: ContractFilters = {}): Promise => { + 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 => { + const response = await api.get<{ success: boolean; data: Contract }>(`${HR_BASE}/contracts/${id}`); + return response.data.data; + }, + + create: async (data: CreateContractDto): Promise => { + 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 => { + const payload: Record = {}; + 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 => { + await api.delete(`${HR_BASE}/contracts/${id}`); + }, + + activate: async (id: string): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + const payload: Record = {}; + 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 => { + await api.delete(`${HR_BASE}/leave-types/${id}`); + }, +}; + +// ============================================================================ +// Leaves API +// ============================================================================ + +export const leavesApi = { + getAll: async (filters: LeaveFilters = {}): Promise => { + 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 => { + const response = await api.get<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves/${id}`); + return response.data.data; + }, + + create: async (data: CreateLeaveDto): Promise => { + 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 => { + const payload: Record = {}; + 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 => { + await api.delete(`${HR_BASE}/leaves/${id}`); + }, + + submit: async (id: string): Promise => { + const response = await api.post<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves/${id}/submit`); + return response.data.data; + }, + + approve: async (id: string): Promise => { + 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 => { + const response = await api.post<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves/${id}/reject`, { reason }); + return response.data.data; + }, + + cancel: async (id: string): Promise => { + const response = await api.post<{ success: boolean; data: Leave }>(`${HR_BASE}/leaves/${id}/cancel`); + return response.data.data; + }, +}; diff --git a/src/features/hr/api/index.ts b/src/features/hr/api/index.ts new file mode 100644 index 0000000..97cf585 --- /dev/null +++ b/src/features/hr/api/index.ts @@ -0,0 +1 @@ +export * from './hr.api'; diff --git a/src/features/hr/hooks/index.ts b/src/features/hr/hooks/index.ts new file mode 100644 index 0000000..5a6855a --- /dev/null +++ b/src/features/hr/hooks/index.ts @@ -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'; diff --git a/src/features/hr/hooks/useContracts.ts b/src/features/hr/hooks/useContracts.ts new file mode 100644 index 0000000..db49346 --- /dev/null +++ b/src/features/hr/hooks/useContracts.ts @@ -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; + getById: (id: string) => Promise; + create: (data: CreateContractDto) => Promise; + update: (id: string, data: UpdateContractDto) => Promise; + remove: (id: string) => Promise; + activate: (id: string) => Promise; + terminate: (id: string, terminationDate: string) => Promise; + cancel: (id: string) => Promise; +} + +export function useContracts(options: UseContractsOptions = {}): UseContractsReturn { + const { initialFilters = {}, autoLoad = true } = options; + + const [contracts, setContracts] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(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 => { + return contractsApi.getById(id); + }, []); + + const create = useCallback(async (data: CreateContractDto): Promise => { + const result = await contractsApi.create(data); + await fetchContracts(); + return result; + }, [fetchContracts]); + + const update = useCallback(async (id: string, data: UpdateContractDto): Promise => { + const result = await contractsApi.update(id, data); + await fetchContracts(); + return result; + }, [fetchContracts]); + + const remove = useCallback(async (id: string): Promise => { + await contractsApi.delete(id); + await fetchContracts(); + }, [fetchContracts]); + + const activate = useCallback(async (id: string): Promise => { + const result = await contractsApi.activate(id); + await fetchContracts(); + return result; + }, [fetchContracts]); + + const terminate = useCallback(async (id: string, terminationDate: string): Promise => { + const result = await contractsApi.terminate(id, terminationDate); + await fetchContracts(); + return result; + }, [fetchContracts]); + + const cancel = useCallback(async (id: string): Promise => { + 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, + }; +} diff --git a/src/features/hr/hooks/useDepartments.ts b/src/features/hr/hooks/useDepartments.ts new file mode 100644 index 0000000..6a0481f --- /dev/null +++ b/src/features/hr/hooks/useDepartments.ts @@ -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; + getById: (id: string) => Promise; + create: (data: CreateDepartmentDto) => Promise; + update: (id: string, data: UpdateDepartmentDto) => Promise; + remove: (id: string) => Promise; +} + +export function useDepartments(options: UseDepartmentsOptions = {}): UseDepartmentsReturn { + const { initialFilters = {}, autoLoad = true } = options; + + const [departments, setDepartments] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(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 => { + return departmentsApi.getById(id); + }, []); + + const create = useCallback(async (data: CreateDepartmentDto): Promise => { + const result = await departmentsApi.create(data); + await fetchDepartments(); + return result; + }, [fetchDepartments]); + + const update = useCallback(async (id: string, data: UpdateDepartmentDto): Promise => { + const result = await departmentsApi.update(id, data); + await fetchDepartments(); + return result; + }, [fetchDepartments]); + + const remove = useCallback(async (id: string): Promise => { + 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; + create: (data: CreateJobPositionDto) => Promise; + update: (id: string, data: UpdateJobPositionDto) => Promise; + remove: (id: string) => Promise; +} + +export function useJobPositions(options: UseJobPositionsOptions = {}): UseJobPositionsReturn { + const { includeInactive = false, autoLoad = true } = options; + + const [positions, setPositions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const result = await jobPositionsApi.create(data); + await fetchPositions(); + return result; + }, [fetchPositions]); + + const update = useCallback(async (id: string, data: UpdateJobPositionDto): Promise => { + const result = await jobPositionsApi.update(id, data); + await fetchPositions(); + return result; + }, [fetchPositions]); + + const remove = useCallback(async (id: string): Promise => { + await jobPositionsApi.delete(id); + await fetchPositions(); + }, [fetchPositions]); + + return { + positions, + isLoading, + error, + refresh: fetchPositions, + create, + update, + remove, + }; +} diff --git a/src/features/hr/hooks/useEmployee.ts b/src/features/hr/hooks/useEmployee.ts new file mode 100644 index 0000000..a7bb150 --- /dev/null +++ b/src/features/hr/hooks/useEmployee.ts @@ -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; + loadSubordinates: () => Promise; + create: (data: CreateEmployeeDto) => Promise; + update: (data: UpdateEmployeeDto) => Promise; + remove: () => Promise; + terminate: (terminationDate: string) => Promise; + reactivate: () => Promise; +} + +export function useEmployee(options: UseEmployeeOptions = {}): UseEmployeeReturn { + const { employeeId, autoLoad = true } = options; + + const [employee, setEmployee] = useState(null); + const [subordinates, setSubordinates] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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, + }; +} diff --git a/src/features/hr/hooks/useEmployees.ts b/src/features/hr/hooks/useEmployees.ts new file mode 100644 index 0000000..d241f3d --- /dev/null +++ b/src/features/hr/hooks/useEmployees.ts @@ -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; + getById: (id: string) => Promise; + create: (data: CreateEmployeeDto) => Promise; + update: (id: string, data: UpdateEmployeeDto) => Promise; + remove: (id: string) => Promise; + terminate: (id: string, terminationDate: string) => Promise; + reactivate: (id: string) => Promise; + getSubordinates: (id: string) => Promise; +} + +export function useEmployees(options: UseEmployeesOptions = {}): UseEmployeesReturn { + const { initialFilters = {}, autoLoad = true } = options; + + const [employees, setEmployees] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(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 => { + return employeesApi.getById(id); + }, []); + + const create = useCallback(async (data: CreateEmployeeDto): Promise => { + const result = await employeesApi.create(data); + await fetchEmployees(); + return result; + }, [fetchEmployees]); + + const update = useCallback(async (id: string, data: UpdateEmployeeDto): Promise => { + const result = await employeesApi.update(id, data); + await fetchEmployees(); + return result; + }, [fetchEmployees]); + + const remove = useCallback(async (id: string): Promise => { + await employeesApi.delete(id); + await fetchEmployees(); + }, [fetchEmployees]); + + const terminate = useCallback(async (id: string, terminationDate: string): Promise => { + const result = await employeesApi.terminate(id, terminationDate); + await fetchEmployees(); + return result; + }, [fetchEmployees]); + + const reactivate = useCallback(async (id: string): Promise => { + const result = await employeesApi.reactivate(id); + await fetchEmployees(); + return result; + }, [fetchEmployees]); + + const getSubordinates = useCallback(async (id: string): Promise => { + return employeesApi.getSubordinates(id); + }, []); + + return { + employees, + total, + page, + totalPages, + isLoading, + error, + filters, + setFilters, + refresh: fetchEmployees, + getById, + create, + update, + remove, + terminate, + reactivate, + getSubordinates, + }; +} diff --git a/src/features/hr/hooks/useLeaveTypes.ts b/src/features/hr/hooks/useLeaveTypes.ts new file mode 100644 index 0000000..6f2e599 --- /dev/null +++ b/src/features/hr/hooks/useLeaveTypes.ts @@ -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; + create: (data: CreateLeaveTypeDto) => Promise; + update: (id: string, data: UpdateLeaveTypeDto) => Promise; + remove: (id: string) => Promise; +} + +export function useLeaveTypes(options: UseLeaveTypesOptions = {}): UseLeaveTypesReturn { + const { includeInactive = false, autoLoad = true } = options; + + const [leaveTypes, setLeaveTypes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const result = await leaveTypesApi.create(data); + await fetchLeaveTypes(); + return result; + }, [fetchLeaveTypes]); + + const update = useCallback(async (id: string, data: UpdateLeaveTypeDto): Promise => { + const result = await leaveTypesApi.update(id, data); + await fetchLeaveTypes(); + return result; + }, [fetchLeaveTypes]); + + const remove = useCallback(async (id: string): Promise => { + await leaveTypesApi.delete(id); + await fetchLeaveTypes(); + }, [fetchLeaveTypes]); + + return { + leaveTypes, + isLoading, + error, + refresh: fetchLeaveTypes, + create, + update, + remove, + }; +} diff --git a/src/features/hr/hooks/useLeaves.ts b/src/features/hr/hooks/useLeaves.ts new file mode 100644 index 0000000..9ddee40 --- /dev/null +++ b/src/features/hr/hooks/useLeaves.ts @@ -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; + getById: (id: string) => Promise; + create: (data: CreateLeaveDto) => Promise; + update: (id: string, data: UpdateLeaveDto) => Promise; + remove: (id: string) => Promise; + submit: (id: string) => Promise; + approve: (id: string) => Promise; + reject: (id: string, reason: string) => Promise; + cancel: (id: string) => Promise; +} + +export function useLeaves(options: UseLeavesOptions = {}): UseLeavesReturn { + const { initialFilters = {}, autoLoad = true } = options; + + const [leaves, setLeaves] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(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 => { + return leavesApi.getById(id); + }, []); + + const create = useCallback(async (data: CreateLeaveDto): Promise => { + const result = await leavesApi.create(data); + await fetchLeaves(); + return result; + }, [fetchLeaves]); + + const update = useCallback(async (id: string, data: UpdateLeaveDto): Promise => { + const result = await leavesApi.update(id, data); + await fetchLeaves(); + return result; + }, [fetchLeaves]); + + const remove = useCallback(async (id: string): Promise => { + await leavesApi.delete(id); + await fetchLeaves(); + }, [fetchLeaves]); + + const submit = useCallback(async (id: string): Promise => { + const result = await leavesApi.submit(id); + await fetchLeaves(); + return result; + }, [fetchLeaves]); + + const approve = useCallback(async (id: string): Promise => { + const result = await leavesApi.approve(id); + await fetchLeaves(); + return result; + }, [fetchLeaves]); + + const reject = useCallback(async (id: string, reason: string): Promise => { + const result = await leavesApi.reject(id, reason); + await fetchLeaves(); + return result; + }, [fetchLeaves]); + + const cancel = useCallback(async (id: string): Promise => { + 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, + }; +} diff --git a/src/features/hr/index.ts b/src/features/hr/index.ts new file mode 100644 index 0000000..24d549f --- /dev/null +++ b/src/features/hr/index.ts @@ -0,0 +1,10 @@ +// HR Feature - Barrel Export + +// Types +export * from './types'; + +// API +export * from './api'; + +// Hooks +export * from './hooks'; diff --git a/src/features/hr/types/hr.types.ts b/src/features/hr/types/hr.types.ts new file mode 100644 index 0000000..a8dd4f6 --- /dev/null +++ b/src/features/hr/types/hr.types.ts @@ -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 = { + active: 'Activo', + inactive: 'Inactivo', + on_leave: 'Con permiso', + terminated: 'Dado de baja', +}; + +export const EMPLOYEE_STATUS_COLORS: Record = { + 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 = { + permanent: 'Indefinido', + temporary: 'Temporal', + contractor: 'Por honorarios', + internship: 'Practicas', + part_time: 'Medio tiempo', +}; + +export const CONTRACT_STATUS_LABELS: Record = { + draft: 'Borrador', + active: 'Activo', + expired: 'Vencido', + terminated: 'Terminado', + cancelled: 'Cancelado', +}; + +export const CONTRACT_STATUS_COLORS: Record = { + 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 = { + draft: 'Borrador', + submitted: 'Enviada', + approved: 'Aprobada', + rejected: 'Rechazada', + cancelled: 'Cancelada', +}; + +export const LEAVE_STATUS_COLORS: Record = { + 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 = { + vacation: 'Vacaciones', + sick: 'Enfermedad', + personal: 'Personal', + maternity: 'Maternidad', + paternity: 'Paternidad', + bereavement: 'Luto', + unpaid: 'Sin goce', + other: 'Otro', +}; + +export const WAGE_TYPE_LABELS: Record = { + 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' }, +]; diff --git a/src/features/hr/types/index.ts b/src/features/hr/types/index.ts new file mode 100644 index 0000000..98364a1 --- /dev/null +++ b/src/features/hr/types/index.ts @@ -0,0 +1 @@ +export * from './hr.types'; diff --git a/src/pages/hr/ContractsPage.tsx b/src/pages/hr/ContractsPage.tsx new file mode 100644 index 0000000..7f0e1d0 --- /dev/null +++ b/src/pages/hr/ContractsPage.tsx @@ -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(''); + const [selectedType, setSelectedType] = useState(''); + const [contractToDelete, setContractToDelete] = useState(null); + const [contractToActivate, setContractToActivate] = useState(null); + const [contractToTerminate, setContractToTerminate] = useState(null); + const [contractToCancel, setContractToCancel] = useState(null); + const [showNewModal, setShowNewModal] = useState(false); + + const [newContract, setNewContract] = useState>({ + 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[] = [ + { + key: 'contract', + header: 'Contrato', + render: (contract) => ( +
+
{contract.name}
+ {contract.reference &&
{contract.reference}
} +
+ ), + }, + { + key: 'employee', + header: 'Empleado', + render: (contract) => ( +
+
{contract.employeeName}
+
{contract.employeeNumber}
+
+ ), + }, + { + key: 'type', + header: 'Tipo', + render: (contract) => ( + {CONTRACT_TYPE_LABELS[contract.contractType]} + ), + }, + { + key: 'status', + header: 'Estado', + render: (contract) => ( + + {CONTRACT_STATUS_LABELS[contract.status]} + + ), + }, + { + key: 'dates', + header: 'Vigencia', + render: (contract) => ( +
+
{formatDate(contract.dateStart, 'short')}
+
+ {contract.dateEnd ? formatDate(contract.dateEnd, 'short') : 'Indefinido'} +
+
+ ), + }, + { + key: 'wage', + header: 'Salario', + render: (contract) => ( +
+
+ {formatCurrency(contract.wage, contract.currency || 'MXN')} +
+
{WAGE_TYPE_LABELS[contract.wageType || 'monthly']}
+
+ ), + }, + { + key: 'actions', + header: '', + render: (contract) => { + const items: DropdownItem[] = [ + { + key: 'view', + label: 'Ver detalle', + icon: , + onClick: () => navigate(`/hr/employees/${contract.employeeId}`), + }, + ]; + + if (contract.status === 'draft') { + items.push({ + key: 'activate', + label: 'Activar', + icon: , + onClick: () => setContractToActivate(contract), + }); + items.push({ + key: 'edit', + label: 'Editar', + icon: , + onClick: () => navigate(`/hr/contracts/${contract.id}/edit`), + }); + items.push({ + key: 'delete', + label: 'Eliminar', + icon: , + danger: true, + onClick: () => setContractToDelete(contract), + }); + } + + if (contract.status === 'active') { + items.push({ + key: 'terminate', + label: 'Terminar', + icon: , + onClick: () => setContractToTerminate(contract), + }); + items.push({ + key: 'cancel', + label: 'Cancelar', + icon: , + danger: true, + onClick: () => setContractToCancel(contract), + }); + } + + return ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Contratos

+

+ Gestiona los contratos laborales de tus empleados +

+
+
+ + +
+
+ +
+ + +
+
+ +
+
+
Total
+
{total}
+
+
+
+
+ + handleStatusFilter('active')}> + +
+
+ +
+
+
Activos
+
{activeCount}
+
+
+
+
+ + handleStatusFilter('draft')}> + +
+
+ +
+
+
Borradores
+
{draftCount}
+
+
+
+
+ + handleStatusFilter('expired')}> + +
+
+ +
+
+
Vencidos
+
{expiredCount}
+
+
+
+
+
+ + + + Lista de Contratos + + +
+
+
+ + 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" + /> +
+ + + + + + {(searchTerm || selectedStatus || selectedType) && ( + + )} +
+ + {contracts.length === 0 && !isLoading ? ( + + ) : ( + setFilters({ page: p }), + }} + /> + )} +
+
+
+ + setShowNewModal(false)} + title="Nuevo Contrato" + size="lg" + > +
+
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+ + 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" + /> +
+
+
+ + +
+
+
+ + setContractToDelete(null)} + onConfirm={handleDeleteContract} + title="Eliminar contrato" + message={`¿Eliminar el contrato "${contractToDelete?.name}"? Esta accion no se puede deshacer.`} + variant="danger" + confirmText="Eliminar" + /> + + setContractToActivate(null)} + onConfirm={handleActivateContract} + title="Activar contrato" + message={`¿Activar el contrato "${contractToActivate?.name}"?`} + variant="success" + confirmText="Activar" + /> + + setContractToTerminate(null)} + onConfirm={handleTerminateContract} + title="Terminar contrato" + message={`¿Terminar el contrato "${contractToTerminate?.name}"? Se registrara con fecha de hoy.`} + variant="warning" + confirmText="Terminar" + /> + + setContractToCancel(null)} + onConfirm={handleCancelContract} + title="Cancelar contrato" + message={`¿Cancelar el contrato "${contractToCancel?.name}"? Esta accion no se puede deshacer.`} + variant="danger" + confirmText="Cancelar contrato" + /> +
+ ); +} + +export default ContractsPage; diff --git a/src/pages/hr/DepartmentsPage.tsx b/src/pages/hr/DepartmentsPage.tsx new file mode 100644 index 0000000..3fd2ae0 --- /dev/null +++ b/src/pages/hr/DepartmentsPage.tsx @@ -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; + 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: , + onClick: () => onEdit(department), + }, + { + key: 'delete', + label: 'Eliminar', + icon: , + danger: true, + onClick: () => onDelete(department), + }, + ]; + + return ( +
+
+
+ {hasChildren ? ( + + ) : ( +
+ )} +
+ +
+
+
+ {department.name} + {department.code && ( + {department.code} + )} + {!department.isActive && ( + Inactivo + )} +
+
+ {department.managerName && Gerente: {department.managerName}} + {department.employeeCount !== undefined && ( + + + {department.employeeCount} empleados + + )} +
+
+
+ + + + } + items={items} + align="right" + /> +
+ {hasChildren && isExpanded && ( +
+ {children.map(child => ( + + ))} +
+ )} +
+ ); +} + +export function DepartmentsPage() { + const [searchTerm, setSearchTerm] = useState(''); + const [expanded, setExpanded] = useState>(new Set()); + const [showDeptModal, setShowDeptModal] = useState(false); + const [showPositionModal, setShowPositionModal] = useState(false); + const [editingDept, setEditingDept] = useState(null); + const [editingPosition, setEditingPosition] = useState(null); + const [deptToDelete, setDeptToDelete] = useState(null); + const [positionToDelete, setPositionToDelete] = useState(null); + const [activeTab, setActiveTab] = useState<'departments' | 'positions'>('departments'); + + const [deptForm, setDeptForm] = useState>({ companyId: '' }); + const [positionForm, setPositionForm] = useState>({}); + + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Departamentos y Puestos

+

+ Gestiona la estructura organizacional de tu empresa +

+
+
+ + {activeTab === 'departments' ? ( + + ) : ( + + )} +
+
+ +
+ + +
+ + + +
+ {activeTab === 'departments' ? 'Estructura Organizacional' : 'Catalogo de Puestos'} +
+ + 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" + /> +
+
+
+ + {isLoading || loadingPositions ? ( +
+ +
+ ) : activeTab === 'departments' ? ( + filteredDepartments.length === 0 ? ( + + ) : ( +
+ {(searchTerm ? filteredDepartments : rootDepartments).map(dept => ( + + ))} +
+ ) + ) : filteredPositions.length === 0 ? ( + + ) : ( +
+ {filteredPositions.map(position => ( +
+
+
+

{position.name}

+ {position.departmentName && ( +

{position.departmentName}

+ )} + {!position.isActive && ( + Inactivo + )} +
+ + + + } + items={[ + { key: 'edit', label: 'Editar', icon: , onClick: () => handleEditPosition(position) }, + { key: 'delete', label: 'Eliminar', icon: , danger: true, onClick: () => setPositionToDelete(position) }, + ]} + align="right" + /> +
+ {position.description && ( +

{position.description}

+ )} + {(position.minSalary || position.maxSalary) && ( +

+ Rango: ${position.minSalary?.toLocaleString() || '0'} - ${position.maxSalary?.toLocaleString() || 'N/A'} +

+ )} +
+ ))} +
+ )} +
+
+ + { setShowDeptModal(false); setEditingDept(null); }} + title={editingDept ? 'Editar Departamento' : 'Nuevo Departamento'} + > +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+ + +
+
+ + setDeptForm({ ...deptForm, color: e.target.value })} + className="mt-1 h-10 w-full rounded-md border border-gray-300" + /> +
+
+ +