Leave, Schedules & Attendance
Business view
This is the everyday HR loop: who's scheduled, who's off, who came in late, and who's owed overtime — with the regional rules Gulf clinics need baked in.
Leave. Staff request leave (a date range, or a few hours for hourly leave types); managers approve or reject; managers can also assign leave directly. Paid leave is capped at the remaining balance; unpaid is uncapped. Annual entitlement can step up with tenure (e.g. 21 → 30 days after N years), blended fairly across the anniversary year. When you approve, a non-blocking coverage warning tells you how many teammates are already off on those dates.
Schedules. Each employee's working week comes from their branch, an offset, or a custom per-day schedule. Reduced-hours periods (Ramadan, summer) shift the day — a later start and a shorter day — company-wide or per branch.
Attendance & overtime. Lateness is measured against the expected start (with a grace window). Overtime only pays if it's approved (when the company requires approval) — unapproved extra time is dropped, not quietly paid. And if someone forgets to check out, the day is capped (default 16h) and flagged auto-closed so one forgotten shift can't invent a fortune in overtime.
Technical view
Leave workflow (resolvers/mutations/actions/hr/hrMutations.js)
createLeaveRequest(:205) — target =args.user || requester. Requesting
for someone else needs DO_ALL/APPROVE_LEAVE (:211). Hourly types need start/end on a single day (:228); day types count working days via countLeaveDays (:41, excludes holidays/off-days, half-day = 0.5). Paid balance cap (:242): blocks if requested > remaining — and a paid type with no balance entry is treated as 0 (:247), so it can't be over-booked; unpaid is uncapped. A manager assigning to someone else auto-approves (:258).
reviewLeave→approveLeaveRequest/rejectLeaveRequest(:314,:344)
— tenant-scoped, must be PENDING, and self-approval is blocked: if (leave.userId === session.user.id) throw hr.cannotReviewOwnLeave (:320).
cancelLeaveRequest(:348) — own request, or any with
DO_ALL/APPROVE_LEAVE.
Leave balance math (resolvers/queries/actions/hr/hrQueries.js)
computeLeaveBalancesBatch (:77) is the single source of truth. It batch-loads company inputs once and filters per user to kill N+1s. Key pieces:
- Tenure bump (
:108): whenPayrollSettings.tenureLeaveEnabledand the
service anniversary at tenureLeaveAfterYears (default 5) is reached, entitlement becomes max(base, tenureLeaveDays) (default 30). In the anniversary year it's a day-accurate blend: base × beforeFraction + bump × (1 − beforeFraction).
- Accrual (
:130): incremental accrual for the ANNUAL type when enabled,
prorated by elapsed periods, capped at the annual total.
- Remaining (
:175): paid day types →
max(0, entitlement + carriedOver − used − pending); unpaid → always 0 (uncapped); floored at 0.
Coverage warning (hrQueries.js:235)
leaveCoverage finds peers in the same department intersected with the target's branch (falls back to branch-only if no department), excludes the target, and returns APPROVED overlapping leaves — { scope, peers, onLeave, overlaps }. Purely informational; approval isn't blocked.
Scheduling engine (utils/hr/schedule.js)
- Reduced-hours periods —
resolveDay(:120): a matching active
SchedulePeriod first applies startDelayHours (later start, also the lateness baseline) then subtracts reduceHoursBy from the end; the day clamps to ≥ 0.
- Schedule sources (
:96):INHERIT_BRANCH,OFFSET(± minutes),CUSTOM
(per-weekday JSON).
- Defaults (
:185,:243):latenessGraceMinutes10,overtimeDailyThresholdMinutes
0, maxDailyWorkedHours 16, observeBranchHolidays true.
SchedulePeriodmodel:schema.prisma:3979.
Overtime & the checkout guard
- Overtime (
accounting/payroll/attendanceSummary.js): `dayOtPayable =
!overtimeRequiresApproval || rec.otApproved (:92) — when approval is required, unapproved excess is dropped **and** worked time is capped at scheduled minutes so it can't leak into base (:157). Regular-day OT at overtimeMultiplier (1.5) and rest-day/holiday work at restDayOvertimeMultiplier (2) are separate buckets (:197`).
- Missing-checkout guard (
resolvers/mutations/userMutations.js:816): a
session longer than maxDailyWorkedHours × 3600 seconds is capped and autoClosed = true persisted on the Attendance (:868). A stale session force-closed at the next check-in also sets autoClosed (:672).
setOvertimeApproval(hrMutations.js:163, gatedMANAGE_ATTENDANCE) — sets
Attendance.overtimeApproved (new field at schema.prisma:2245).
Web UI
- Leave queue:
HrTabs.js › LeaveTab(:129) at/hr/leave, with the
Assign Leave modal and the coverage-warning modal. Self-service request: RequestLeaveModal.js:14.
- Reduced-hours:
SchedulePeriods.js(Settings sub-tab). - Attendance:
HrAttendance.jsat/hr/attendance.
Validation
upsertSchedulePeriodInput (inputRules.js:4148), upsertLeaveTypeInput (:4130), upsertHolidayInput (:4139). Numeric ranges (reduce/delay hours 0–12, at least one non-zero) are enforced imperatively in hrMutations.js:132.