Aller au contenu

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:

public record RejectLeaveRequestRequest(
    @NotBlank String rejectionReason
) {}

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_DOCUMENT status) → 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 tenantId from ScopedValue<TenantContext>
  • Pessimistic lock (SELECT FOR UPDATE) is tenant-scoped
  • LeaveApproved includes tenantId for 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 boundaries
  • docs/standards/database-guidelines.md — §SELECT FOR UPDATE pattern, §BigDecimal for financial calculations, §Version-based optimistic concurrency
  • docs/standards/api-guidelines.md — §PATCH endpoint patterns, §ApiResponse envelope, §409 Conflict handling
  • docs/standards/react-typescript-guidelines.md — §Component patterns, §CSS Modules, §i18n, §Gesture handling
  • docs/standards/design-system.md — §BottomSheet, §Button, §Badge, §Toast, §DataTable, §Modal
  • docs/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 published
  • LeaveRequestService.approveRequest(): status != PENDING_APPROVAL → 422
  • LeaveRequestService.approveRequest(): PENDING_DOCUMENT (doc missing) → 422
  • LeaveRequestService.approveRequest(): balance now insufficient → 422
  • LeaveRequestService.approveRequest(): concurrent approval (version mismatch) → 409
  • LeaveRequestService.rejectRequest(): valid rejection with reason → REJECTED, notification sent
  • LeaveRequestService.rejectRequest(): blank reason → 422
  • BalanceMutationService.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}/reject with reason → 200
  • PATCH /api/v1/absence/leave-requests/{id}/reject without 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