HR API
Employees, org structure, attendance, and leave. Conventions (pagination, filter params, error envelope, permission model): conventions.md. Full field-level contracts: Swagger (README.md). For a guided full-stack trace of the employees feature, see ../learn/employees-walkthrough.md.
Employees — /api/employees
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/employees/me | employee.profile.view | Own profile (self-service) |
| PATCH | /api/employees/me | employee.profile.view | Update own contact details |
| GET | /api/employees | hr.employees.view | Paged/filtered list (grid backend) |
| GET | /api/employees/{id} | hr.employees.view | One employee |
| POST | /api/employees | hr.employees.create | Create |
| PUT | /api/employees/{id} | hr.employees.edit | Full update |
| PATCH | /api/employees/{id}/status | hr.employees.delete | Change employment status |
| PATCH | /api/employees/{id}/archive | hr.employees.delete | Soft-delete |
| PATCH | /api/employees/{id}/restore | hr.employees.delete | Un-archive |
Two module-wide behaviors:
- Soft delete — "delete" is archival:
archiveflips theis_deletedcolumn, every list/spec excludes archived rows by default, and?archived=truereturns only the archive. Nothing is physically removed;restorebrings a row back. - Unique government IDs — SSS, PhilHealth, TIN, and Pag-IBIG numbers are
unique across employees. Create/update pre-checks them and returns 409
(e.g.
"SSS number is already assigned to another employee") instead of letting the DB constraint surface as a 500.
Deep-dive: POST /api/employees
Request (EmployeeCreateRequest — all listed fields required unless noted):
{
"lastName": "Reyes",
"firstName": "Ana",
"middleName": "L",
"nationality": "Filipino",
"birthday": "1994-03-12",
"dateHired": "2026-08-01",
"address": "123 Rizal St, Quezon City",
"phoneNumber": "0917-555-1234",
"sssNumber": "34-1234567-8",
"philhealthNumber": "12-345678901-2",
"tinNumber": "123-456-789-000",
"pagibigNumber": "1234-5678-9012",
"status": "PROBATIONARY",
"positionId": 5,
"basicSalary": 35000,
"riceSubsidy": 1500,
"phoneAllowance": 1000,
"clothingAllowance": 800,
"grossSemiMonthlyRate": 17500,
"hourlyRate": 208.33
}
Validation highlights: status must be PROBATIONARY | REGULAR | INACTIVE;
all money fields must be ≥ 0; optional separationDate +
separationReason (T|TR|R|D). middleName and nationality are optional.
Pay is hours-based — hourlyRate is what the payroll engine actually
multiplies; the salary fields feed contributions and reporting.
Responses: 200 with the created EmployeeDto (same fields plus generated
employeeNumber and a nested position summary); 400 on validation
failure; 409 on a duplicate government ID.
Deep-dive: GET /api/employees (paged + filtered)
The reference implementation of the grid filtering convention:
Pageable params plus per-column filters —
lastNameSearch/lastNameSearchType, firstNameSearch/...Type,
status/statusFilterType, departmentId, positionId,
employeeIdMin/Max/FilterType, positionSearch, departmentSearch,
salaryMin/Max/salaryFilterType, hireDateFrom/To/FilterType,
archived, and fields (column projection).
GET /api/employees?page=0&size=25&sort=lastName,asc&statusFilterType=equals&status=REGULAR&salaryMin=30000
Response: the standard PageResponseDto envelope of EmployeeDtos — or, when
fields=lastName,firstName,basicSalary is present, of flat maps holding only
those columns (the enterprise grid derives fields from its visible columns).
Departments — /api/departments and Positions — /api/positions
DepartmentController.java, PositionController.java — identical permission pattern:
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/departments · /api/positions | hr.employees.view | List all |
| GET | .../search | hr.employees.view | Paged/filtered grid list |
| GET | .../{id} | hr.employees.view | One record |
| POST | ... | hr.employees.manage | Create |
| PUT | .../{id} | hr.employees.manage | Update |
| DELETE | .../{id} | hr.employees.manage | Delete |
| PATCH | .../{id}/archive · .../{id}/restore | hr.employees.manage | Soft-delete / restore |
Departments form a hierarchy — each department may have a
parentDepartment — and positions belong to a department, which is how an
employee (→ position → department) rolls up for org reporting.
Timesheets — /api/timesheets
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/timesheets/me | employee.timesheet.view | Own entries |
| POST | /api/timesheets/clock-in | employee.timesheet.view | Clock in (today) |
| POST | /api/timesheets/clock-out | employee.timesheet.view | Clock out (today) |
| POST | /api/timesheets/{id}/submit | employee.timesheet.view | Submit own entry for approval |
| POST | /api/timesheets | hr.timesheets.manage | Create an entry for an employee |
| GET | /api/timesheets | hr.timesheets.view | Paged/filtered list |
| GET | /api/timesheets/{id} | hr.timesheets.view | One entry |
| PUT | /api/timesheets/{id} | hr.timesheets.manage | Edit an entry |
| PATCH | /api/timesheets/{id}/status | hr.timesheets.manage | Approve / disapprove |
| DELETE | /api/timesheets/{id} | hr.timesheets.manage | Delete |
Status workflow: Not Submitted → (employee submits) → Submitted →
(approver decides) → Approved | Disapproved. The state machine is enforced
in the entity itself and violations return 422
(InvalidTimesheetStateException): clocking out twice, submitting an entry
that isn't Not Submitted, future work dates, timeOut before timeIn.
Submitting someone else's entry is 403
(UnauthorizedTimesheetActionException: "You can only submit your own timesheet entries").
Deep-dive: PATCH /api/timesheets/{id}/status (approval)
{ "status": "Approved" }
status accepts the display names "Approved" or "Disapproved" (any other
value, including "Submitted", is rejected — 422 "Status must be 'Approved' or 'Disapproved'"). Only a timesheet currently in Submitted may be decided
(otherwise 422 "Only 'Submitted' timesheets can be approved or disapproved").
The caller's own employee record is stamped as the approver on the entry.
Response: 200 with the updated TimesheetDto.
Leave — /api/leave-requests, /api/leave-balances, /api/leave-types
LeaveRequestController.java, LeaveBalanceController.java, LeaveTypeController.java
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/leave-requests/me | employee.leave.history.view | Own request history |
| POST | /api/leave-requests | employee.leave.request | File a request (leaveTypeId, startDate, endDate, optional description) |
| DELETE | /api/leave-requests/{id} | employee.leave.request | Cancel own pending request |
| GET | /api/leave-requests | hr.leave.requests.view | Paged/filtered list |
| GET | /api/leave-requests/{id} | hr.leave.requests.view | One request |
| POST | /api/leave-requests/{id}/approve | hr.leave.requests.approve | Approve |
| POST | /api/leave-requests/{id}/reject | hr.leave.requests.reject | Reject |
| GET | /api/leave-balances/me | employee.leave.balance.view | Own balances per leave type |
| GET | /api/leave-types | employee.leave.request or hr.leave.requests.view | Leave-type lookup |
Deep-dive: POST /api/leave-requests/{id}/approve
No request body. Only a Pending request can be decided (otherwise 400
"Only pending leave requests can be approved"). Approval computes the
chargeable days of the range, checks the employee's balance for that leave
type, and decrements the balance atomically with the approval — if the
balance is short, the whole call fails with 400
"Employee has insufficient leave balance for <type>" and nothing changes.
On success the request becomes Approved, is marked paid, and records the
caller as approver with the decision timestamp; response is the updated
LeaveRequestDto. /reject mirrors this without touching the balance.
Overtime — /api/overtime-requests
OvertimeRequestController.java
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/overtime-requests/me | employee.overtime.request | Own requests |
| POST | /api/overtime-requests | employee.overtime.request | File a request |
| PUT | /api/overtime-requests/{id} | employee.overtime.request | Edit own pending request |
| DELETE | /api/overtime-requests/{id} | employee.overtime.request | Cancel own request |
| GET | /api/overtime-requests | hr.overtime.requests.view or payroll.overtime.approve | Paged/filtered list |
| GET | /api/overtime-requests/{id} | hr.overtime.requests.view or payroll.overtime.approve | One request |
| PATCH | /api/overtime-requests/{id}/decision | hr.overtime.requests.approve or payroll.overtime.approve | Approve / reject |
Approved overtime is what the payroll engine pays out at the overtime multiplier (payroll.md).
Work suspensions — /api/work-suspensions
WorkSuspensionController.java — company-declared no-work days (typhoons etc.) that the payroll engine treats like calendar events alongside holidays.
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/work-suspensions | payroll.holidays.manage or payroll.manage | List |
| POST | /api/work-suspensions | payroll.holidays.manage | Declare a suspension date |
| DELETE | /api/work-suspensions/{id} | payroll.holidays.manage | Remove |