Story CP-017: Employee CRUD + Matricule¶
Module: control-plane Slice: Slice 5 from architecture (split from CP-005) Brand context: Papillon HR Suite (mobile-first, offline-first, warm UX) Priority: 5 Depends on: CP-005 Estimated complexity: L
⚠️ Pre-existing event stubs (2026-04-22) — The record classes
EmployeeCreated,EmployeeUpdated, andEmployeeTerminatedwere created ahead of this story on branchfeat/control-plane-employee-eventsto unblock consumers (AB-003, AB-022, QR-002). This story still owns publishing these events (wiring them into the service layer,@TransactionalEventListenercontracts, and integration tests). Do not re-create the records; extend them only if the Control Plane needs additional fields.
Objective¶
Deliver the Employee entity with online CRUD, server-assigned matricule (immutable), status filtering, and the default approver fallback. This story creates the core employee data model and the online employee management experience. Offline creation, manager history, and PRE_HIRE transition are in CP-018.
Backend Scope¶
Entities & Records¶
Employee entity (tenant DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK, client-generated |
| tenant_id | UUID | NOT NULL |
| matricule | String | Nullable (assigned by server, unique within tenant) |
| first_name | String | NOT NULL, min 2, max 100 |
| last_name | String | NOT NULL, min 2, max 100 |
| String | Nullable (not all employees have email) | |
| phone | String | Nullable |
| contract_type_id | UUID | NOT NULL, FK to ContractType |
| org_unit_id | UUID | NOT NULL, FK to OrgUnit |
| position_id | UUID | NOT NULL, FK to Position |
| category_id | UUID | NOT NULL, FK to EmployeeCategory (derived from position or directly set) |
| hire_date | LocalDate | NOT NULL |
| manager_id | UUID | Nullable, FK to Employee (self-referencing) |
| status | EmployeeStatus enum | NOT NULL |
| status_effective_date | LocalDate | NOT NULL |
| version | long | NOT NULL, default 0 (optimistic locking) |
| created_date | Instant | NOT NULL |
| updated_date | Instant | NOT NULL |
EmployeeStatus enum:
Note: ON_LEAVE is NOT a status. It's derived: IF status=ACTIVE AND an approved leave overlaps today, the UI shows "En conge" badge.
DTOs:
public record EmployeeResponse(UUID id, String matricule, String firstName, String lastName,
String email, String phone, String contractTypeCode, String orgUnitName, String positionTitle,
String categoryCode, LocalDate hireDate, @Nullable UUID managerId,
@Nullable String managerName, EmployeeStatus status, LocalDate statusEffectiveDate) {}
public record EmployeeListResponse(List<EmployeeResponse> employees, int total) {}
public record CreateEmployeeRequest(UUID id, String firstName, String lastName,
@Nullable String email, @Nullable String phone, UUID contractTypeId, UUID orgUnitId,
UUID positionId, UUID categoryId, LocalDate hireDate, @Nullable UUID managerId) {}
public record UpdateEmployeeRequest(@Nullable String firstName, @Nullable String lastName,
@Nullable String email, @Nullable String phone, @Nullable UUID contractTypeId,
@Nullable UUID orgUnitId, @Nullable UUID positionId, @Nullable UUID categoryId,
@Nullable LocalDate hireDate, @Nullable UUID managerId, long version) {}
Repository Layer¶
EmployeeRepository.java— Tenant-aware:findAll(filters, Pageable) → Page<Employee>findById(UUID) → Optional<Employee>findByEmail(String) → Optional<Employee>findByManagerId(UUID) → List<Employee>countByStatus(EmployeeStatus) → intsave(Employee) → Employee
Service Layer¶
EmployeeService.java:
- list(filters, Pageable) → EmployeeListResponse — Filterable by status, org_unit, category, search text
- getById(UUID) → EmployeeResponse
- create(CreateEmployeeRequest) → EmployeeResponse — Validates UUID v7 format from client. Assigns matricule via MatriculeService. Sets status based on hire_date (future = PRE_HIRE, today/past = ACTIVE). Publishes EmployeeCreated event. Validates org_unit is ACTIVE. Validates contract_type, position, category exist and are active.
- update(UUID, UpdateEmployeeRequest) → EmployeeResponse — Optimistic locking via version field. Matricule is immutable (ignored if present in request). Publishes EmployeeUpdated event.
- getManagerOrDefault(UUID employeeId) → ManagerResponse — Returns employee's manager, or tenant's default approver if manager_id is null.
MatriculeService.java:
- assignMatricule(UUID tenantId, LocalDate hireDate) → String — Thread-safe, per-tenant sequential numbering. Uses FOR UPDATE on a counter table partitioned by year-month. Format: {settings.matriculePrefix}-{YEAR}-{MM}-{zeroPadded(counter, settings.matriculePadding)}. Example: ALT-2026-03-001, ALT-2026-03-002. Follows CLAUDE.md rule 15: {PREFIX}-{YEAR}-{MM}-{SEQ} where PREFIX = company code trigram (3-6 uppercase alphanumeric chars, set in TenantSettings.matriculePrefix during provisioning). Matricule is immutable once assigned.
- Counter table: matricule_counters(tenant_id, year_month, prefix, padding, last_number) — initialized at 1 per year-month during first use.
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| GET | /api/v1/employees | ?status=&orgUnit=&search= | EmployeeListResponse | ROLE_DG, ROLE_RH_MANAGER, ROLE_MANAGER (direct reports only) |
| GET | /api/v1/employees/{id} | — | EmployeeResponse | ROLE_DG, ROLE_RH_MANAGER, ROLE_MANAGER (if direct report), self |
| POST | /api/v1/employees | CreateEmployeeRequest | EmployeeResponse | ROLE_DG, ROLE_RH_MANAGER |
| PATCH | /api/v1/employees/{id} | UpdateEmployeeRequest | EmployeeResponse | ROLE_DG, ROLE_RH_MANAGER |
| GET | /api/v1/employees/{id}/manager | — | {managerId, managerName} or default approver |
Any authenticated |
Validation Rules¶
- UUID v7 format validated (client-generated)
first_name: min 2, max 100last_name: min 2, max 100email: valid format if provided, unique within tenantphone: unique within tenant if providedhire_date: not more than 1 year in the futurecontract_type_id,org_unit_id,position_id: must reference ACTIVE entities within the same tenant- Matricule: assigned by server, immutable once set — update requests cannot change it
- Optimistic locking:
versionfield must match current DB version (409 on stale)
Multi-Tenant Considerations¶
- Employee entity is in the tenant DB and tenant-scoped
- EmployeeRepository extends TenantAwareRepository
- Matricule counters are per-tenant
Flyway Migrations (Tenant DB)¶
V005__create_employee.sql—employeestable,matricule_counterstable
Note:
manager_historytable is in CP-018.
Frontend Scope¶
Pages & Routes¶
/rh/employees— Employee list/rh/employees/:id— Employee detail/rh/employees/new— Employee creation form/rh/employees/:id/edit— Employee edit form
Components¶
EmployeeListPage.tsx— Filterable list with status filter chipsEmployeeCard.tsx— Employee list item (mobile card view)EmployeeRow.tsx— Employee table row (desktop view)EmployeeDetailPage.tsx— Single employee detail viewEmployeeFormPage.tsx— Create and edit employee form
UI States (ALL REQUIRED)¶
Employee List:
| State | Behavior | French Copy |
|---|---|---|
| Loading | Skeleton: 5 employee cards | — |
| Empty | Illustration + message + CTA | "Aucun employe pour le moment.\nAjoutez votre premier collaborateur !" CTA: "Ajouter un employe" |
| Error | Red toast | "Une erreur est survenue. Veuillez reessayer." |
| Success (create online) | Green toast | "Employe cree" |
Note: Offline states (orange badge, offline toast) are in CP-018.
Employee card states:
| State | Visual |
|---|---|
| Active | Green dot |
| Pre-hire | Blue dot + "Embauche le DD/MM" |
| Suspended | Orange dot + "Suspendu" badge |
| Terminated | Grey dot + "Fin de contrat" badge |
Note: Pending sync state (orange left border + sync icon) is in CP-018.
Responsive Behavior¶
Employee List: - 360px: Card list. Each card: avatar, name, matricule (or "Numero en attente" if not yet assigned), position, department, status dot. FAB "Nouvel employe" at bottom. - 768px: Two-column card grid. - 1024px+: Table view with columns: Matricule, Nom complet, Poste, Departement, Categorie, Contrat, Statut, Manager. Column sorting. Multi-filter bar.
Employee Form: - 360px: Single column, all fields stacked. - 1024px+: Two column form.
Interactions¶
Employee creation (online): 1. Tap "Nouvel employe" --> form page 2. Fill fields. Position field = PositionCombobox from CP-005 (search existing OR type new --> auto-creates) 3. Category auto-fills from selected position (editable if needed) 4. Manager field = employee search combobox 5. Submit --> UUID v7 generated client-side --> POST /api/v1/employees --> matricule assigned --> toast "Employe cree"
Acceptance Criteria¶
AC-034: Create employee online
Given I am RH Manager and online
When I submit the employee form with valid data
Then the employee is created with a UUID v7 ID (client-generated)
And the server assigns a matricule (e.g., "ALT-2026-03-001")
And I see the toast "Employe cree"
AC-037: Employee list with status filters
Given 12 employees exist (10 active, 1 pre-hire, 1 terminated)
When I view the employee list with no filter
Then I see "12 employe(s)" header
When I filter by status "Actif"
Then I see only the 10 active employees
AC-040: Matricule is immutable
Given employee "Jean" has matricule "ALT-2026-03-003"
When any update is made to the employee
Then the matricule remains "ALT-2026-03-003" (cannot be changed)
AC-041: Employee without manager routes to default approver
Given employee "Jean" has no manager (manager_id=null)
When GET /api/v1/employees/{id}/manager is called
Then it returns the tenant's default approver user
OHADA & Regulatory Rules¶
- Code du Travail Art. 92.3 (Registre d'employeur): The Employee entity stores baseline fields required for the employer register. Full register in HR Core module.
- CDD 2-year limit (Art. 15.10): CDD contracts exceeding 2 years cumulative automatically become CDI. The rule is stored in ContractType.max_duration_months (from CP-005). Alerts at 90/30/7 days — enforcement in Employee Management module, but the data model supports it from day one.
- Convention Collective Interprofessionnelle (CCI): Employee categories (Cadre, AM, Employe, Ouvrier) from CP-005 affect CNPS contribution rates.
- Decret 2024-901: Employee count tracking (countByStatus) enables the >10 workers threshold check for staff representative elections.
- Employee data is personal data (Loi 2013-450): first_name, last_name, email, phone are PII. Audit trail tracks all modifications. Terminated employees are never hard-deleted (AUDCIF Art. 24).
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §Entities, §UUID v7, §Optimistic locking, §Application Eventsdocs/standards/database-guidelines.md— §Self-referencing FKs, §Counter tables (FOR UPDATE), §Tenant-scoped unique constraintsdocs/standards/api-guidelines.md— §CRUD patterns, §Pagination, §Filteringdocs/standards/react-typescript-guidelines.md— §List/card/table components, §Form components, §Combobox pattern, §FAB
Testing Requirements¶
Unit Tests¶
- MatriculeService: sequential numbering, zero-padding, custom prefix
- MatriculeService: thread-safety (concurrent calls produce unique matricules)
- EmployeeService.create: PRE_HIRE status for future hire_date, ACTIVE for today/past
- EmployeeService.create: validates UUID v7 format from client
- EmployeeService.create: rejects inactive org unit assignment
- EmployeeService.update: matricule immutability (ignored in update)
- EmployeeService.update: optimistic locking (stale version → 409)
- EmployeeService.getManagerOrDefault: returns default approver when manager_id is null
Integration Tests¶
- POST /api/v1/employees — full creation flow with matricule assignment
- POST /api/v1/employees — duplicate email within tenant → 409
- GET /api/v1/employees — pagination, filtering, search
- GET /api/v1/employees/{id}/manager — with and without manager
- Tenant isolation: all entities scoped to tenant
- Optimistic locking: concurrent updates → 409 on stale version
What QA Will Validate¶
- Employee list displays correctly on all breakpoints (cards on mobile, table on desktop)
- Employee creation form works online
- Position combobox: search existing, create new (escape hatch from CP-005)
- Category auto-fills from position selection
- Matricule assigned and displayed after creation
- Status filters work on employee list
- Status dots display correctly for each employee state
Out of Scope¶
- Employee offline creation + sync — CP-018
- Manager hierarchy historization — CP-018
- PRE_HIRE automatic transition — CP-018
- Employee list offline mode — CP-018
- Full employee dossier (photo, emergency contacts, bank info, documents) — HR Core module
- Employee onboarding/offboarding workflows — Employee Management module
- Bulk employee import — V2
- Employee self-service profile view — V2
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend pages render correctly at 360px, 768px, 1024px
- [ ] All UI states implemented (loading, empty, error, 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 (no cross-tenant data leaks)
- [ ] Matricule assignment is thread-safe and unique
- [ ] No TypeScript
any— all types explicit - [ ] No Java raw types — all generics explicit
- [ ] API responses follow standard envelope format
- [ ] Audit trail entries created for every data mutation