Story AB-012: Leave Approval & Rejection (Backend + Frontend)¶
@assignee: @joel23p
Module: absence-management Slice: VS-AB-006 + VS-AB-008 from architecture Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 12 Depends on: AB-010 Can develop concurrently with: AB-011, AB-013 Merge order: After AB-010 Estimated complexity: M PRD User Stories: US-ABS-002
Objective¶
Enable managers, HR managers, and DGs to approve or reject pending leave requests. Approval triggers pessimistic-lock balance deduction (SELECT FOR UPDATE on leave_employee_profile, insert LEAVE_DEBIT ledger entry, publish LeaveApproved). Rejection requires a mandatory reason and publishes a notification.
Backend Scope¶
Entities & Records¶
Request records:
Events published:
public record LeaveApproved(
UUID tenantId,
UUID employeeId,
UUID leaveRequestId,
LeaveType leaveType,
LocalDate startDate,
LocalDate endDate,
BigDecimal numberOfDays, // jours calendaires
BigDecimal numberOfDaysOuvrables, // source of truth for balance deduction
boolean isPaid,
BigDecimal payRate, // 100 for full pay, 50 for half, 0 for unpaid
@Nullable SickLeavePhase sickLeavePhase, // FULL_PAY|HALF_PAY|ZERO_PAY|null (only for SICK)
boolean halfDayStart,
boolean halfDayEnd
) {}
Repository Layer¶
Uses existing repositories from AB-002:
- LeaveRequestRepository.findByIdAndTenantId(UUID id, UUID tenantId)
- LeaveEmployeeProfileRepository.findByTenantIdAndEmployeeIdForUpdate(UUID tenantId, UUID employeeId) — SELECT FOR UPDATE
- LeaveBalanceLedgerRepository.save(LeaveBalanceLedger entry)
Service Layer¶
LeaveRequestService.java — approveRequest(UUID requestId):
1. Load request, verify status == PENDING_APPROVAL
2. Verify caller has MANAGER (direct report) or HR_MANAGER/DG role
3. Lock LeaveEmployeeProfile row via SELECT FOR UPDATE (pessimistic lock)
4. Re-validate balance >= numberOfDays (balance may have changed since request was created)
5. Insert LEAVE_DEBIT entry in LeaveBalanceLedger
6. Update cached balance on LeaveEmployeeProfile (decrement)
7. Increment LeaveEmployeeProfile.version (optimistic concurrency check)
8. Update request status to APPROVED, set approvedAt, approverId
9. Publish LeaveApproved (for payroll, notifications)
10. Return updated response
LeaveRequestService.java — rejectRequest(UUID requestId, String rejectionReason):
1. Load request, verify status == PENDING_APPROVAL
2. Verify caller has MANAGER (direct report) or HR_MANAGER/DG role
3. Validate rejectionReason is not blank
4. Update request status to REJECTED, set rejectionReason, rejectedAt, rejectorId
5. Publish notification event (employee sees rejection reason)
6. Return updated response
BalanceMutationService.java:
- deductBalance(UUID tenantId, UUID employeeId, BigDecimal days): pessimistic lock pattern
- Uses SELECT FOR UPDATE on leave_employee_profile row
- Version increment for concurrent approval detection
- Second concurrent approval → 409 Conflict (version mismatch)
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| PATCH | /api/v1/absence/leave-requests/{id}/approve |
{} |
LeaveRequestResponse |
MANAGER (direct reports), HR_MANAGER, DG |
| PATCH | /api/v1/absence/leave-requests/{id}/reject |
RejectLeaveRequestRequest |
LeaveRequestResponse |
MANAGER (direct reports), HR_MANAGER, DG |
Validation Rules¶
- Can only approve/reject requests with status
PENDING_APPROVAL - Cannot approve if supporting document is required but missing (
PENDING_DOCUMENTstatus) → 422 - Rejection reason is mandatory (not blank) → 422
"Veuillez indiquer le motif du refus." - Concurrent approvals: second approval gets 409 Conflict (version mismatch after lock)
- MANAGER can only approve/reject direct reports' requests
- Balance re-validation on approval: if balance is now insufficient → 422
Multi-Tenant Considerations¶
- All queries include
tenantIdfromScopedValue<TenantContext> - Pessimistic lock (
SELECT FOR UPDATE) is tenant-scoped LeaveApprovedincludestenantIdfor downstream consumers- Works across all 4 DB profiles
Frontend Scope¶
OFFLINE SYNC REMINDER (Papillon): This story involves data mutations (approve/reject). Approval and rejection actions REQUIRE an active internet connection — they CANNOT be performed offline because they involve pessimistic locks and balance deduction. When offline: display the list from cache but disable approve/reject buttons with tooltip "Cette action necessite une connexion internet". See
docs/standards/offline-sync-guidelines.md.
Pages & Routes¶
| Route | Page | Description |
|---|---|---|
/absences/approbations |
PendingApprovalsPage | List of pending leave requests for approval |
/absences/approbations/{id} |
ApprovalDetailPage | Individual request detail with approve/reject actions |
Components¶
S4 — Pending Approvals List:
- ApprovalCard — Card showing employee name, leave type icon, dates, day count, balance, time since submission
- InlineApprovalButtons — "Approuver" (primary) + "Rejeter" (ghost) inline on card
- SwipeableCard — Swipe right=approve (green bg), swipe left=reject (red bg, opens BottomSheet)
- SwipeHint — First-time-only hint overlay
S5 — Approval Detail:
- ApprovalDetailCard — Employee name, type, dates, duration, current balance, balance after approval, reason
- MiniTeamCalendar — Embedded timeline showing who else is off during the requested period
- DocumentIndicator — Shows if document is attached
- StickyActionBar — Bottom bar with "Rejeter" (ghost) + "Approuver" (primary)
- RejectionBottomSheet — BottomSheet with mandatory reason textarea + confirm button
- RejectionModal — Desktop variant of rejection flow (Modal instead of BottomSheet)
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | 3 card shimmer rectangles | — |
| Empty (no pending) | EmptyState with checkmark illustration | "Toutes les demandes ont ete traitees !" / CTA: "Voir le calendrier d'equipe" |
| Error (API failure) | Toast | "Impossible de charger les approbations." |
| Offline | List from cache, approve/reject buttons disabled | "Cette action necessite une connexion internet" |
| Success (approve) | Card flashes green + slides out + Toast | "Conge approuve" |
| Success (reject) | Card flashes red + slides out + Toast | "Demande rejetee" |
Responsive Behavior¶
Mobile (360px — Papillon primary): - Card list with inline approve/reject buttons - Swipe gestures: right=approve (green bg reveal), left=reject (red bg reveal, opens BottomSheet) - Swipe hint shown first time only (persisted in localStorage) - Approval detail: full-screen with sticky action bar at bottom - Rejection flow: BottomSheet with mandatory reason textarea + "Confirmer le refus" (danger button, disabled until text entered) + "Annuler" (ghost) - Touch targets >= 48px
Desktop (1024px+ — Enterprise primary): - DataTable with columns: Nom, Type, Dates, Jours, Solde, Actions (approve/reject/detail icons) - If document missing: approve button disabled, shows lock icon with tooltip - Rejection flow: Modal (500px wide) instead of BottomSheet - Detail view in side panel
Interactions¶
- Tap "Approuver" → immediate approval, card flashes green + slides out
- Tap "Rejeter" → opens BottomSheet/Modal for mandatory reason
- Swipe right → approve (with haptic feedback on mobile)
- Swipe left → reject (opens BottomSheet)
- Detail view: mini team calendar shows conflicts
- Document missing → approve button disabled with lock icon
French Micro-Copy¶
approval.approve: "Approuver"approval.reject: "Rejeter"approval.reason_label: "Motif du refus"approval.reason_placeholder: "Expliquez la raison du refus..."approval.reason_helper: "L'employe recevra ce motif dans sa notification."approval.confirm_reject: "Confirmer le refus"approval.doc_missing: "En attente du justificatif"approval.swipe_hint: "Glissez pour approuver ou rejeter"approval.team_impact: "{n} autre(s) personne(s) absente(s) sur cette periode"error.rejection_required: "Veuillez indiquer le motif du refus."
Acceptance Criteria¶
AC-039: Manager sees full request context for approval
Given a leave request is pending
When I view it
Then I see: employee name, leave type, dates, number of days, employee's current balance, team calendar showing who else is off during that period
AC-040: Rejection requires mandatory reason
Given I reject a request
When I submit the rejection
Then I must provide a reason (mandatory text field) and the employee is notified with the reason
AC-041: Document check blocks approval
Given the leave type requires a supporting document (sick leave, maternity)
When the document is not uploaded
Then I cannot approve — the approve button is disabled with explanation
OHADA & Regulatory Rules¶
- FR-ABS-060 (CT Art. 25.13): Approval follows org-unit hierarchy — direct manager approves, HR_MANAGER and DG can approve any request.
- FR-ABS-061 (CT Art. 25.14): Approve or reject mechanics — approval triggers balance deduction; rejection requires a reason communicated to the employee.
- FR-ABS-052 (CT Art. 25.15): Document visibility — approver sees document filename only; HR/DG can view document content.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §REST controller patterns, §Pessimistic locking, §Application Events, §Transaction boundariesdocs/standards/database-guidelines.md— §SELECT FOR UPDATE pattern, §BigDecimal for financial calculations, §Version-based optimistic concurrencydocs/standards/api-guidelines.md— §PATCH endpoint patterns, §ApiResponse envelope, §409 Conflict handlingdocs/standards/react-typescript-guidelines.md— §Component patterns, §CSS Modules, §i18n, §Gesture handlingdocs/standards/design-system.md— §BottomSheet, §Button, §Badge, §Toast, §DataTable, §Modaldocs/standards/offline-sync-guidelines.md— §Actions requiring connectivity, §Disabled state when offline
Testing Requirements¶
Unit Tests¶
LeaveRequestService.approveRequest(): valid PENDING_APPROVAL → APPROVED, ledger entry created, event publishedLeaveRequestService.approveRequest(): status != PENDING_APPROVAL → 422LeaveRequestService.approveRequest(): PENDING_DOCUMENT (doc missing) → 422LeaveRequestService.approveRequest(): balance now insufficient → 422LeaveRequestService.approveRequest(): concurrent approval (version mismatch) → 409LeaveRequestService.rejectRequest(): valid rejection with reason → REJECTED, notification sentLeaveRequestService.rejectRequest(): blank reason → 422BalanceMutationService.deductBalance(): correct deduction with pessimistic lock- RBAC: MANAGER can only approve direct reports, not other teams
Integration Tests¶
- PATCH
/api/v1/absence/leave-requests/{id}/approve→ 200, balance decremented in DB - PATCH
/api/v1/absence/leave-requests/{id}/rejectwith reason → 200 - PATCH
/api/v1/absence/leave-requests/{id}/rejectwithout reason → 422 - Concurrent approval: two simultaneous PATCH approve → first succeeds, second gets 409
- Multi-tenant isolation: manager in tenant A cannot approve request in tenant B
What QA Will Validate¶
- Pending approvals list renders at 360px with inline buttons
- Swipe right approves with green flash animation
- Swipe left opens BottomSheet for rejection reason
- Rejection BottomSheet: confirm button disabled until reason entered
- Detail view shows mini team calendar with overlapping absences
- Document missing: approve button disabled with lock icon and tooltip
- Offline: buttons disabled with connectivity message
- Desktop: DataTable with sortable columns and modal rejection flow
Out of Scope¶
- Self-approval for DG (future)
- Multi-level approval chains (V2)
- Batch approve multiple requests (Enterprise V2)
- Creating requests (AB-010)
- Cancelling requests (AB-013)
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend pages render correctly at 360px, 768px, 1024px
- [ ] All 5 UI states implemented (loading, empty, error, offline, success)
- [ ] French micro-copy matches design spec exactly
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Multi-tenant isolation verified — approval scoped to tenant, RBAC enforced
- [ ] Offline write+sync works (Papillon) / N/A (Enterprise) — Approve/reject disabled offline, list cached
- [ ] No TypeScript
any - [ ] No Java raw types — all generics explicit
- [ ] API responses follow standard envelope format
- [ ] Audit trail entries for every data mutation — LeaveApproved published, LEAVE_DEBIT ledger entry, rejection reason logged