Story QR-003 :¶
@assignee: @m-kouassi
Module de configuration et d'affectation des plannings de travail : qr-attendance.
Segment : QR-T1-002.
Contexte de la marque : [LES DEUX] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite.
Priorité : 3.
Dépend de : QR-001.
Peut être développé en parallèle avec : QR-002, QR-004, QR-005.
Ordre de fusion : Avant QR-006.
Complexité estimée : M.
PRD User Stories : US-QR-009.
Objectif¶
Permettre aux RH de créer et de gérer des plannings de travail avec une configuration horaire journalière (début, fin, plages de pause, activation/désactivation du suivi des pauses), puis d'attribuer ces plannings à des employés individuels ou à des services entiers. Les affectations au niveau des employés prévalent sur les valeurs par défaut au niveau des services. Les plannings constituent la base de la détection des retards, du calcul des heures supplémentaires et du calcul des heures travaillées dans les stories en aval.
Portée du backend¶
Migrations Flyway¶
V001_004__attendance_work_schedule.sql :
CREATE TABLE attendance_work_schedule (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_work_schedule_tenant ON attendance_work_schedule(tenant_id);
CREATE TABLE attendance_work_schedule_day (
id UUID PRIMARY KEY,
schedule_id UUID NOT NULL REFERENCES attendance_work_schedule(id),
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
is_working_day BOOLEAN NOT NULL,
start_time TIME,
end_time TIME,
break_start_time TIME,
break_end_time TIME,
break_tracking_enabled BOOLEAN NOT NULL DEFAULT false,
UNIQUE(schedule_id, day_of_week)
);
V001_005__calendrier_des_présences_et_affectation.sql
:
CREATE TABLE attendance_employee_schedule_assignment (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
employee_id UUID NOT NULL,
schedule_id UUID NOT NULL REFERENCES attendance_work_schedule(id),
effective_from DATE NOT NULL,
effective_to DATE,
UNIQUE(tenant_id, employee_id, effective_from)
);
CREATE INDEX idx_emp_schedule_tenant ON attendance_employee_schedule_assignment(tenant_id, employee_id, effective_from, effective_to);
CREATE TABLE attendance_department_schedule_assignment (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
department_id UUID NOT NULL,
schedule_id UUID NOT NULL REFERENCES attendance_work_schedule(id),
effective_from DATE NOT NULL,
effective_to DATE,
UNIQUE(tenant_id, department_id, effective_from)
);
CREATE INDEX idx_dept_schedule_tenant ON attendance_department_schedule_assignment(tenant_id, department_id, effective_from, effective_to);
```### Entités et enregistrements
**WorkSchedule**
(base de données du tenant) :
| Field | Type | Constraints |
|-------|------|-------------|
| id | UUID | PK |
| tenantId | UUID | NOT NULL |
| name | String | NOT NULL, max 100 |
| isActive | boolean | NOT NULL, default true |
| createdAt | OffsetDateTime | NOT NULL |
| updatedAt | OffsetDateTime | NOT NULL |
**WorkScheduleDay**
(base de données du tenant) :
| Field | Type | Constraints |
|-------|------|-------------|
| id | UUID | PK |
| scheduleId | UUID | NOT NULL, FK to WorkSchedule |
| dayOfWeek | short | NOT NULL, 1=Mon..7=Sun |
| isWorkingDay | boolean | NOT NULL |
| startTime | LocalTime | Required if isWorkingDay=true |
| endTime | LocalTime | Required if isWorkingDay=true |
| breakStartTime | LocalTime | Nullable |
| breakEndTime | LocalTime | Nullable |
| breakTrackingEnabled | boolean | NOT NULL, default false |
**Affectation des horaires des employés**
(base de données du tenant) :
| Field | Type | Constraints |
|-------|------|-------------|
| id | UUID | PK |
| tenantId | UUID | NOT NULL |
| employeeId | UUID | NOT NULL |
| scheduleId | UUID | NOT NULL, FK to WorkSchedule |
| effectiveFrom | LocalDate | NOT NULL |
| effectiveTo | LocalDate | Nullable (null = ongoing) |
**Département, Calendrier, Mission**
(base de données des tenants) :
| Field | Type | Constraints |
|-------|------|-------------|
| id | UUID | PK |
| tenantId | UUID | NOT NULL |
| departmentId | UUID | NOT NULL |
| scheduleId | UUID | NOT NULL, FK to WorkSchedule |
| effectiveFrom | LocalDate | NOT NULL |
| effectiveTo | LocalDate | Nullable (null = ongoing) |
**DTO :**
```java
public record CreateScheduleRequest(
@NotBlank @Size(max = 100) String name,
@NotEmpty List<ScheduleDayRequest> days
) {}
public record ScheduleDayRequest(
@NotNull short dayOfWeek,
@NotNull boolean isWorkingDay,
LocalTime startTime,
LocalTime endTime,
LocalTime breakStartTime,
LocalTime breakEndTime,
boolean breakTrackingEnabled
) {}
public record UpdateScheduleRequest(
@Size(max = 100) String name,
List<ScheduleDayRequest> days,
Boolean isActive
) {}
public record ScheduleResponse(
UUID id,
String name,
boolean isActive,
List<ScheduleDayResponse> days,
OffsetDateTime createdAt,
OffsetDateTime updatedAt
) {}
public record ScheduleDayResponse(
short dayOfWeek,
boolean isWorkingDay,
LocalTime startTime,
LocalTime endTime,
LocalTime breakStartTime,
LocalTime breakEndTime,
boolean breakTrackingEnabled
) {}
public record AssignEmployeeRequest(
@NotNull UUID employeeId,
@NotNull LocalDate effectiveFrom,
LocalDate effectiveTo
) {}
public record AssignDepartmentRequest(
@NotNull UUID departmentId,
@NotNull LocalDate effectiveFrom,
LocalDate effectiveTo
) {}
```### Couche de référentiels
- `WorkScheduleRepository.java` :
- `findByTenantId(UUID tenantId) -> List<WORKSCHEDULE>`
- `findByTenantIdAndId(UUID tenantId, UUID id) -> Optional<WORKSCHEDULE>`
- `WorkScheduleDayRepository.java` :
- `findByScheduleId(UUID scheduleId) -> List<WorkScheduleDay>`
- `EmployeeScheduleAssignmentRepository.java` :
- `findByTenantIdAndEmployeeId(UUID tenantId, UUID employeeId) -> List<EmployeeScheduleAssignment>`
- `findActiveByTenantIdAndEmployeeIdAndDate(UUID tenantId, UUID employeeId, LocalDate date) -> Optional<EmployeeScheduleAssignment>` — Recherche une affectation où effectiveFrom <= date ET (effectiveTo EST NULL OU effectiveTo >= date)
- `DepartmentScheduleAssignmentRepository.java` :
- `findByTenantIdAndDepartmentId(UUID tenantId, UUID departmentId) -> List<DepartmentScheduleAssignment>`
- `findActiveByTenantIdAndDepartmentIdAndDate(UUID tenantId, UUID departmentId, LocalDate date) -> Optional<DepartmentScheduleAssignment>`
### Couche de service
**ScheduleService.java:**
- `createSchedule(UUID tenantId, CreateScheduleRequest) -> ScheduleResponse` — Crée un planning avec toutes les configurations journalières, publie un événement d'audit
- `listSchedules(UUID tenantId) -> List<ScheduleResponse>` — Renvoie tous les plannings avec leurs configurations journalières
- `getSchedule(UUID tenantId, UUID scheduleId) -> ScheduleResponse` — Renvoie un seul planning avec les jours
- `updateSchedule(UUID tenantId, UUID scheduleId, UpdateScheduleRequest) -> ScheduleResponse` — Met à jour le nom de l'horaire, son statut actif et/ou ses configurations journalières
**ScheduleAssignmentService.java:**
- `assignToEmployee(UUID tenantId, UUID scheduleId, AssignEmployeeRequest) -> void` — Crée une affectation au niveau de l'employé
- `assignToDepartment(UUID tenantId, UUID scheduleId, AssignDepartmentRequest) -> void` — Crée une affectation au niveau du service
- `getEffectiveSchedule(UUID tenantId, UUID employeeId, LocalDate date) -> ScheduleResponse` — Logique de résolution :
1. Vérifier si une affectation au niveau de l'employé est active à la `date`
2. S'il n'y en a pas, rechercher le departmentId de l'employé (à partir de CachedEmployeeData), vérifier si une affectation au niveau du service est active à la `date`
3. Si aucune n'est trouvée, renvoyer null (aucun planning attribué)
### Endpoints API
| Method | Path | Request Body | Response | Auth |
|--------|------|-------------|----------|------|
| POST | `/api/v1/qrcontr/schedules` | CreateScheduleRequest | ApiResponse<ScheduleResponse> | HR (P2) |
| GET | `/api/v1/qrcontr/schedules` | — | ApiResponse<List<ScheduleResponse>> | HR (P2) |
| GET | `/api/v1/qrcontr/schedules/{scheduleId}` | — | ApiResponse<ScheduleResponse> | HR (P2) |
| PUT | `/api/v1/qrcontr/schedules/{scheduleId}` | UpdateScheduleRequest | ApiResponse<ScheduleResponse> | HR (P2) |
| POST | `/api/v1/qrcontr/schedules/{scheduleId}/assign-employee` | AssignEmployeeRequest | ApiResponse<Void> | HR (P2) |
| POST | `/api/v1/qrcontr/schedules/{scheduleId}/assign-department` | AssignDepartmentRequest | ApiResponse<Void> | HR (P2) |
| GET | `/api/v1/qrcontr/employees/{employeeId}/schedule` | — | ApiResponse<ScheduleResponse> | HR, Manager, Employee (own) |
### Règles de validation
- Le champ « name » est obligatoire ; 100 caractères maximum
- Pour chaque jour : si « isWorkingDay=true », les champs « startTime » et « endTime » sont obligatoires
- « endTime » doit être postérieur à « startTime ». Erreur : « L'heure de fin doit être postérieure à l'heure de début. »
- `breakEndTime` doit être postérieur à `breakStartTime`
- `breakStartTime` / `breakEndTime` doivent se situer dans la plage `startTime`-`endTime`
- `dayOfWeek` doit être compris entre 1 et 7, sans doublons par planning
- Le dimanche (7) est défini par défaut sur `isWorkingDay=false`, mais peut être défini sur true
### Considérations relatives au multi-tenant
- Tous les tableaux incluent la colonne `tenant_id`
- Toutes les requêtes DOIVENT filtrer par `tenant_id`
- La résolution de l'affectation d'horaire utilise `CachedEmployeeData.departmentId` pour le repli sur le service
- La dérogation de l'employé prévaut à tout moment sur l'affectation au service
---
## Portée du frontend
### Pages et routes
- `/qrcontr/schedules` — Page de liste des horaires (RH uniquement)
- `/qrcontr/schedules/new` — Formulaire de création d'horaire
- `/qrcontr/schedules/:scheduleId` — Modification de l'horaire et de l'affectation
### Composants
Les composants suivent le wireframe 3.11 des spécifications de conception :
- `ScheduleListPage.tsx` — Liste des horaires avec le nombre d'affectations
- `ScheduleForm.tsx` — Configuration journalière (lun-dim), champ Nom
- `ScheduleDayRow.tsx` — Basculer entre les jours ouvrés, champs d'heure, champs de pause, bascule de suivi des pauses
- `ScheduleAssignmentPanel.tsx` — Sélecteur d'affectation à un employé/service
### États de l'interface utilisateur (TOUS OBLIGATOIRES)
| State | Behavior | French Copy |
|-------|----------|-------------|
| Loading | Skeleton for schedule list | — |
| Empty | Guidance illustration | "Aucun horaire configure. Definissez un horaire pour activer le suivi des retards et heures supplementaires." |
| Error | Field-level validation | "L'heure de fin doit etre apres l'heure de debut." |
| Offline | View cached schedules, create/edit disabled | "Disponible en ligne uniquement" |
| Success | Schedule card slides in | "Horaire cree." / "Horaire mis a jour." |
### Comportement adaptatif
- **360px (mobile)** : formulaire vertical en pleine largeur, un jour à la fois (accordéon)
- **768px (tablette)** : formulaire en pleine largeur, tous les jours visibles, défilable
- **1024px+ (ordinateur de bureau)** : mise en page côte à côte — liste des horaires à gauche, formulaire à droite
### Interactions
- **Basculateur de jour** : la case à cocher « Jour de travail » active ou désactive la visibilité des champs d'heure (animation de glissement, 200 ms)
- **Champs d'heure** : préremplis avec des valeurs par défaut intelligentes (08h00-17h00, pause 12h00-14h00)
- Bouton **« Appliquer à tous les jours »** : copie les horaires du jour actuel sur tous les jours activés
- **Enregistrement automatique** : les champs sont enregistrés dès qu'ils ne sont plus sélectionnés, une coche verte apparaît brièvement
---
## Critères d'acceptation
```gherkin
AC-001: Per-day schedule configuration
Given I am HR creating a new schedule named "Bureau Standard"
When I configure it
Then I can set different start/end times for each day (Monday through Saturday)
And Sunday is off by default
AC-002: Lunch break window configuration
Given I am configuring a work schedule
When I set up a day
Then I can define a lunch break window with start and end times for calculation purposes
AC-003: Break tracking toggle
Given I am configuring a work schedule
When I configure a day
Then I can enable or disable break tracking (4 scans/day when enabled)
AC-004: Schedule assignment to employee or department
Given I have created a work schedule
When I assign it
Then I can assign it to individual employees or to a department
And all employees in that department inherit the schedule unless they have an individual override
OHADA et dispositions réglementaires¶
- Code du travail de la CI, art. 21.2 : durée hebdomadaire légale = 40 heures. Les horaires doivent être établis en fonction de cette base.
- Travail le dimanche : autorisé, mais classé dans la catégorie d'heures supplémentaires SUNDAY_HOLIDAY.
Normes et conventions¶
docs/standards/java-Spring-guidelines.md— Enregistrements d'entités, modèles de repositories, couche de servicesdocs/standards/api-guidelines.md— Enveloppe ApiResponse, format des erreurs de validationdocs/standards/database-guidelines.md— Conception de tables multi-tenant, tenant_id dans toutes les requêtesdocs/standards/react-typescript-guidelines.md— Structure des composants, modèles de formulaires, hooks
Exigences de test¶
Tests unitaires¶
- Validation du jour de planification : isWorkingDay=true nécessite startTime/endTime
- Validation de la plage horaire : endTime doit être postérieur à startTime
- La pause doit se situer dans la fenêtre start-end
- Résolution effective de l'horaire : l'affectation d'un employé prévaut sur l'affectation d'un service
- Résolution effective de l'horaire : recours au service par défaut en l'absence d'affectation d'un employé
Tests d'intégration¶
- POST /schedules -> 201, horaire créé avec les jours
- GET /schedules -> renvoie tous les horaires avec les configurations de jours
- PUT /schedules/{id} -> met à jour l'horaire et les jours
- POST /schedules/{id}/assign-employee -> crée une affectation
- POST /schedules/{id}/assign-department -> crée une affectation
- GET /employees/{id}/schedule -> renvoie l'horaire effectif (priorité de l'employé > service)
- Isolation des locataires : le locataire A ne peut pas voir les horaires du locataire B
Ce que le contrôle qualité va valider¶
- La page de liste des plannings s'affiche correctement sur tous les points de rupture (360px, 768px, 1024px)
- Créer un formulaire de planning avec configuration par jour
- Le bouton de basculement de jour masque/affiche les champs d'heure
- « Appliquer à tous les jours » copie les horaires sur tous les jours
- Affectation à un employé et à un service
- Vérifier que la dérogation de l'employé a priorité sur celle du service
- L'état vide affiche un message d'aide
- L'état hors ligne affiche les données mises en cache et bloque les modifications
Hors du périmètre¶
- Enregistrement des événements d'horloge (QR-006)
- Détection des retards/avances à l'aide des plannings (QR-009)
- Calcul des heures supplémentaires à l'aide des plannings (QR-018)
- Attribution groupée de plannings (Enterprise V2)
Définition de « terminé »¶
- [ ] Tous les endpoints backend sont implémentés et renvoient des réponses correctes
- [ ] Toutes les pages frontend s'affichent correctement à 360px, 768px et 1024px
- [ ] Les 5 états de l'interface utilisateur sont implémentés (chargement, vide, erreur, hors ligne, succès)
- [ ] Les micro-textes en français correspondent exactement aux spécifications de conception
- [ ] Tous les critères d'acceptation sont validés manuellement
- [ ] Tests unitaires rédigés et réussis
- [ ] Tests d'intégration rédigés et réussis
- [ ] Isolation multi-tenant vérifiée
- [ ] Écriture et synchronisation hors ligne : N/A (la gestion des plannings se fait uniquement en ligne)
- [ ] Pas de
anyen TypeScript, pas de types bruts en Java - [ ] Les réponses API respectent le format d'enveloppe standard
- [ ] Entrées de piste d'audit pour la création, la mise à jour et les modifications d'affectation des plannings