Leave management — Articles 61-68 in code
9 leave types per Oman Labour Law. Auto-numbered LV/{YYYY}/{NNNN} requests · 2-step approval (manager + HR) · auto-balance deduction · year-end carry-forward cron · M17 email integration · My-Leave self-service portal. Eligibility validated server-side: gender, religion, minimum service, balance, overlap.
The 9 seeded leave types
| Type | Days | Pay | Eligibility | Article |
|---|---|---|---|---|
| Annual | 30/yr | Full | All staff after > 6 months | Art. 61 |
| Sick | 70/yr split | 1-14 days 100% · 15-42 days 75% · 43-70 days 50% | All | Art. 62 |
| Casual | 6/yr | Full | All | Firm policy |
| Hajj | 15 once-per-service | Full | Muslim, > 1 year service | Art. 67 |
| Maternity | 50 | Full | Female | Art. 83 |
| Paternity | 7 | Full | Male | Firm policy |
| Bereavement | 3 | Full | All · close family | Firm policy |
| Marriage | 3 once | Full | All | Firm policy |
| Unpaid | per approval | None | All · approval required | — |
Per-leave-type configuration
Annual entitlement. Drives the auto-materialised balance row each year.
How many days roll over at year-end. Annual leave typically max 30; sick / casual typically 0.
Single-request cap. Prevents one 60-day annual-leave block.
Filter at apply-time. Hajj only Muslim, maternity only female, etc.
True for sick (medical certificate) and hajj (visa proof). Apply form enforces.
Drives day calculation. Annual: don't count Fri-Sat or holidays. Sick: counts everything.
Annual: 180; Hajj: 365; others usually 0.
Sick: 100/75/50 by band. Annual: 100. Unpaid: 0.
The balance auto-materialisation
For each (employee × leave_type × calendar_year), employee_leave_balances has one row with:
- opening_balance — carried from prior year (capped by max_carry_forward)
- accrued — default_days_per_year, materialised on Jan 1
- adjustments — manual ± by HR
- taken — sum of approved + completed leave_requests
- balance — computed column = opening + accrued + adjustments − taken
The balance is recomputed on every status transition via LeaveService::recalculateBalance().
The application workflow
Validation chain (LeaveService::apply)
- Dates: from ≤ to · not in past · not too far future
- Eligibility: gender (maternity female, paternity male) · religion (hajj Muslim) · service days >= minimum
- Hajj: once per total service — checked across all prior approved hajj requests
- Balance: requested days ≤ current balance (computed live)
- Per-request cap: requested days ≤ leave_type.max_per_request
- Required attachment: if leave_type.requires_attachment, file present
- Overlap: no other approved/pending leave covering same dates
Failures return inline form errors; nothing persists until all checks pass.
Year-end carry-forward cron
bin/cron-m13-leave-carry-forward.php runs Jan 1 at 02:00 (Asia/Muscat). For each (employee × leave_type):
- Compute prior-year remaining balance
- Cap by
leave_type.max_carry_forward - Materialise new-year row with that capped balance as opening + default accrued
- Idempotent on re-run (UPDATE existing rows)
Step-by-step — apply for leave
Self-service: My Leave portal
Logged-in employee → My Leave. Balance hero per leave type. Click + Apply.
Form
Type · from · to · half-day flag (morning/afternoon) · reason · handover_to (employee picker) · attachment (if required).
Submit
Validation chain runs. On success: auto-numbered
LV/{YYYY}/{NNNN}· 2-step approval chain built · M17 email queued to manager · status pending.Manager decides
HR Leave Dashboard at
/hr/leaveshows pending decisions. Manager clicks Approve / Reject (with comment). Status: pending → mgr-approved (or rejected).HR approves (step 2)
If the firm has a HR step, HR approves to flip to approved. Balance auto-deducted. M17 email to applicant.
Cancel — refund
If applicant cancels approved leave,
LeaveService::cancel()refunds the balance + adds an audit-log entry.
Open HR → Leave Dashboard. 4 stat tiles (pending · approved · upcoming · stuck). Pending decisions table with inline approve/reject. Calendar overlay band shows upcoming leave. This is the partner's "who's where" view.
Hajj leave is once-per-total-service, not once-per-year. The eligibility check walks all prior approved hajj requests across years. Don't try to override at the apply form — the service rejects.
m13.leave.auto_approve_admins=1 short-circuits the approval chain for super_admin / partner roles' own leave (no manager above them). Keeps decision flow sensible at the top of the org.