Aller au contenu

Story AB-012 : Approbation & Refus de congés (Backend + Frontend)

@assignee: @joel23p

Module : absence-management Slice : VS-AB-006 + VS-AB-008 de l'architecture Contexte produit : [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priorité : 12 Dépend de : AB-010 Peut être développé en parallèle avec : AB-011, AB-013 Ordre de merge : Après AB-010 Complexité estimée : M PRD User Stories : US-ABS-002


Objectif

Permettre aux managers, responsables RH et DG d'approuver ou de refuser les demandes de congés en attente. L'approbation déclenche une déduction de solde avec verrou pessimiste (SELECT FOR UPDATE sur leave_employee_profile, insertion d'une entrée LEAVE_DEBIT dans le grand livre, publication de LeaveApproved). Le refus nécessite un motif obligatoire et publie une notification.


Périmètre Backend

Entités & Enregistrements

Enregistrements de demande :

public record RejectLeaveRequestRequest(
    @NotBlank String rejectionReason
) {}

Événements publiés :

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
) {}

Couche Repository

Utilise les repositories existants d'AB-002 : - LeaveRequestRepository.findByIdAndTenantId(UUID id, UUID tenantId) - LeaveEmployeeProfileRepository.findByTenantIdAndEmployeeIdForUpdate(UUID tenantId, UUID employeeId) — SELECT FOR UPDATE - LeaveBalanceLedgerRepository.save(LeaveBalanceLedger entry)

Couche Service

LeaveRequestService.java — approveRequest(UUID requestId) : 1. Charger la demande, vérifier que le statut == PENDING_APPROVAL 2. Vérifier que l'appelant a le rôle MANAGER (collaborateur direct) ou HR_MANAGER/DG 3. Verrouiller la ligne LeaveEmployeeProfile via SELECT FOR UPDATE (verrou pessimiste) 4. Revalider le solde >= numberOfDays (le solde peut avoir changé depuis la création de la demande) 5. Insérer une entrée LEAVE_DEBIT dans LeaveBalanceLedger 6. Mettre à jour le solde en cache sur LeaveEmployeeProfile (décrémentation) 7. Incrémenter LeaveEmployeeProfile.version (vérification de la concurrence optimiste) 8. Mettre à jour le statut de la demande à APPROVED, définir approvedAt, approverId 9. Publier LeaveApproved (pour la paie, les notifications) 10. Retourner la réponse mise à jour

LeaveRequestService.java — rejectRequest(UUID requestId, String rejectionReason) : 1. Charger la demande, vérifier que le statut == PENDING_APPROVAL 2. Vérifier que l'appelant a le rôle MANAGER (collaborateur direct) ou HR_MANAGER/DG 3. Valider que rejectionReason n'est pas vide 4. Mettre à jour le statut de la demande à REJECTED, définir rejectionReason, rejectedAt, rejectorId 5. Publier un événement de notification (l'employé voit le motif du refus) 6. Retourner la réponse mise à jour

BalanceMutationService.java : - deductBalance(UUID tenantId, UUID employeeId, BigDecimal days) : pattern de verrou pessimiste - Utilise SELECT FOR UPDATE sur la ligne leave_employee_profile - Incrémentation de version pour la détection d'approbation concurrente - Deuxième approbation concurrente → 409 Conflict (version mismatch)

Endpoints API

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

Règles de validation

  • Seules les demandes avec le statut PENDING_APPROVAL peuvent être approuvées/refusées
  • Impossible d'approuver si un document justificatif est requis mais manquant (statut PENDING_DOCUMENT) → 422
  • Le motif de refus est obligatoire (non vide) → 422 "Veuillez indiquer le motif du refus."
  • Approbations concurrentes : la deuxième approbation reçoit un 409 Conflict (version mismatch après verrouillage)
  • MANAGER peut uniquement approuver/refuser les demandes de ses collaborateurs directs
  • Revalidation du solde à l'approbation : si le solde est maintenant insuffisant → 422

Considérations multi-tenant

  • Toutes les requêtes incluent tenantId depuis ScopedValue<TenantContext>
  • Le verrou pessimiste (SELECT FOR UPDATE) est filtré par tenant
  • LeaveApproved inclut tenantId pour les consommateurs en aval
  • Fonctionne sur les 4 profils DB

Périmètre Frontend

RAPPEL OFFLINE SYNC (Papillon) : Cette story implique des mutations de données (approbation/refus). Les actions d'approbation et de refus NÉCESSITENT une connexion internet active — elles NE PEUVENT PAS être effectuées hors connexion car elles impliquent des verrous pessimistes et une déduction de solde. Hors connexion : afficher la liste depuis le cache mais désactiver les boutons approbation/refus avec l'infobulle "Cette action nécessite une connexion internet". Voir docs/standards/offline-sync-guidelines.md.

Pages & Routes

Route Page Description
/absences/approbations PendingApprovalsPage Liste des demandes de congés en attente d'approbation
/absences/approbations/{id} ApprovalDetailPage Détail d'une demande individuelle avec actions approbation/refus

Composants

S4 — Liste des approbations en attente : - ApprovalCard — Carte affichant le nom de l'employé, l'icône du type de congé, les dates, le nombre de jours, le solde, le temps écoulé depuis la soumission - InlineApprovalButtons — "Approuver" (primary) + "Rejeter" (ghost) en ligne sur la carte - SwipeableCard — Glisser à droite=approuver (fond vert), glisser à gauche=refuser (fond rouge, ouvre un BottomSheet) - SwipeHint — Superposition d'indication affichée uniquement la première fois

S5 — Détail de l'approbation : - ApprovalDetailCard — Nom de l'employé, type, dates, durée, solde actuel, solde après approbation, motif - MiniTeamCalendar — Chronologie intégrée montrant qui d'autre est absent pendant la période demandée - DocumentIndicator — Indique si un document est joint - StickyActionBar — Barre inférieure avec "Rejeter" (ghost) + "Approuver" (primary) - RejectionBottomSheet — BottomSheet avec textarea de motif obligatoire + bouton de confirmation - RejectionModal — Variante desktop du flux de refus (Modal à la place du BottomSheet)

États UI (TOUS OBLIGATOIRES)

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"

Comportement responsive

Mobile (360px — priorité Papillon) : - Liste de cartes avec boutons approbation/refus en ligne - Gestes de glissement : droite=approuver (révèle un fond vert), gauche=refuser (révèle un fond rouge, ouvre le BottomSheet) - Indication de glissement affichée uniquement la première fois (persistée dans localStorage) - Détail de l'approbation : plein écran avec barre d'action épinglée en bas - Flux de refus : BottomSheet avec textarea de motif obligatoire + "Confirmer le refus" (bouton danger, désactivé jusqu'à saisie du texte) + "Annuler" (ghost) - Cibles de toucher >= 48px

Desktop (1024px+ — priorité Enterprise) : - DataTable avec colonnes : Nom, Type, Dates, Jours, Solde, Actions (icônes approuver/refuser/détail) - Si document manquant : bouton approuver désactivé, affiche une icône cadenas avec infobulle - Flux de refus : Modal (500px de large) à la place du BottomSheet - Vue détaillée dans un panneau latéral

Interactions

  • Appuyer sur "Approuver" → approbation immédiate, la carte clignote en vert + glisse hors de l'écran
  • Appuyer sur "Rejeter" → ouvre le BottomSheet/Modal pour le motif obligatoire
  • Glisser à droite → approuver (avec retour haptique sur mobile)
  • Glisser à gauche → refuser (ouvre le BottomSheet)
  • Vue détaillée : mini calendrier d'équipe affichant les conflits
  • Document manquant → bouton approuver désactivé avec icône cadenas

Micro-copy français

  • approval.approve : "Approuver"
  • approval.reject : "Rejeter"
  • approval.reason_label : "Motif du refus"
  • approval.reason_placeholder : "Expliquez la raison du refus..."
  • approval.reason_helper : "L'employé 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 période"
  • error.rejection_required : "Veuillez indiquer le motif du refus."

Critères d'acceptation

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

Règles OHADA & Réglementaires

  • FR-ABS-060 (CT Art. 25.13) : L'approbation suit la hiérarchie de l'unité organisationnelle — le manager direct approuve, HR_MANAGER et DG peuvent approuver n'importe quelle demande.
  • FR-ABS-061 (CT Art. 25.14) : Mécaniques d'approbation ou de refus — l'approbation déclenche la déduction du solde ; le refus nécessite un motif communiqué à l'employé.
  • FR-ABS-052 (CT Art. 25.15) : Visibilité des documents — l'approbateur voit uniquement le nom du fichier ; HR/DG peuvent consulter le contenu du document.

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

Exigences de test

Tests unitaires

  • LeaveRequestService.approveRequest() : PENDING_APPROVAL valide → APPROVED, entrée de grand livre créée, événement publié
  • LeaveRequestService.approveRequest() : status != PENDING_APPROVAL → 422
  • LeaveRequestService.approveRequest() : PENDING_DOCUMENT (document manquant) → 422
  • LeaveRequestService.approveRequest() : solde maintenant insuffisant → 422
  • LeaveRequestService.approveRequest() : approbation concurrente (version mismatch) → 409
  • LeaveRequestService.rejectRequest() : refus valide avec motif → REJECTED, notification envoyée
  • LeaveRequestService.rejectRequest() : motif vide → 422
  • BalanceMutationService.deductBalance() : déduction correcte avec verrou pessimiste
  • RBAC : MANAGER peut uniquement approuver ses collaborateurs directs, pas d'autres équipes

Tests d'intégration

  • PATCH /api/v1/absence/leave-requests/{id}/approve → 200, solde décrémenté en base
  • PATCH /api/v1/absence/leave-requests/{id}/reject avec motif → 200
  • PATCH /api/v1/absence/leave-requests/{id}/reject sans motif → 422
  • Approbation concurrente : deux PATCH approve simultanés → le premier réussit, le second reçoit 409
  • Isolation multi-tenant : le manager du tenant A ne peut pas approuver une demande du tenant B

Ce que la QA validera

  • La liste des approbations en attente s'affiche à 360px avec les boutons en ligne
  • Glisser à droite approuve avec animation de clignotement vert
  • Glisser à gauche ouvre le BottomSheet pour le motif du refus
  • BottomSheet de refus : le bouton de confirmation est désactivé jusqu'à saisie du motif
  • La vue détaillée affiche le mini calendrier d'équipe avec les absences qui se chevauchent
  • Document manquant : bouton approuver désactivé avec icône cadenas et infobulle
  • Hors connexion : boutons désactivés avec message de connexion
  • Desktop : DataTable avec colonnes triables et flux de refus en modal

Hors périmètre

  • Auto-approbation pour les DG (future)
  • Chaînes d'approbation multi-niveaux (V2)
  • Approbation groupée de plusieurs demandes (Enterprise V2)
  • Création de demandes (AB-010)
  • Annulation de demandes (AB-013)

Définition de Done

  • [ ] Tous les endpoints backend implémentés et retournant les réponses correctes
  • [ ] Toutes les pages frontend s'affichent correctement à 360px, 768px, 1024px
  • [ ] Les 5 états UI implémentés (loading, empty, error, offline, success)
  • [ ] Le micro-copy français correspond exactement au spec design
  • [ ] Tous les critères d'acceptation passent manuellement
  • [ ] Tests unitaires écrits et passants
  • [ ] Tests d'intégration écrits et passants
  • [ ] Isolation multi-tenant vérifiée — approbation filtrée par tenant, RBAC appliqué
  • [ ] Offline write+sync fonctionne (Papillon) / N/A (Enterprise) — Approbation/refus désactivés hors connexion, liste en cache
  • [ ] Pas de any TypeScript
  • [ ] Pas de types raw Java — tous les generics explicites
  • [ ] Les réponses API suivent le format envelope standard
  • [ ] Entrées de piste d'audit pour chaque mutation de données — LeaveApproved publié, entrée de grand livre LEAVE_DEBIT, motif du refus enregistré