Story QR-008: Employee Site Assignment¶
Module: qr-attendance Slice: QR-T2-002 Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 8 Depends on: QR-002 (sites exist) Can develop concurrently with: QR-006, QR-007 Merge order: Any order relative to QR-006+ Estimated complexity: S PRD User Stories: N/A — supports US-QR-001 (flexible site scanning), US-QR-010 (dashboard site filter)
Objective¶
Create the employee-to-site assignment model that supports primary and secondary site designations. When an employee scans a QR code at a non-primary site, the clock event is recorded normally but flagged as a non-primary site scan. This enables site-based filtering on the dashboard and supports future geofencing features.
Backend Scope¶
Entities & Records¶
EmployeeSiteAssignment:
- id (UUID)
- tenantId (UUID)
- employeeId (UUID)
- siteId (UUID)
- isPrimary (boolean, default true)
- effectiveFrom (LocalDate)
- effectiveTo (LocalDate, nullable)
Flyway Migrations¶
V001_014__attendance_employee_site_assignment.sql:
CREATE TABLE attendance_employee_site_assignment (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
employee_id UUID NOT NULL,
site_id UUID NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT true,
effective_from DATE NOT NULL,
effective_to DATE,
UNIQUE(tenant_id, employee_id, site_id, effective_from)
);
CREATE INDEX idx_emp_site_tenant ON attendance_employee_site_assignment(tenant_id, employee_id, is_primary);
Repository Layer¶
SiteAssignmentRepository:
- findByTenantIdAndEmployeeId(UUID tenantId, UUID employeeId) — all assignments for an employee
- findPrimaryByTenantIdAndEmployeeId(UUID tenantId, UUID employeeId) — primary site only
- findBySiteId(UUID tenantId, UUID siteId) — all employees assigned to a site
Service Layer¶
SiteAssignmentService:
- assignEmployeeToSite(AssignEmployeeToSiteRequest) — assign employee to site, enforce at most one primary site per employee at a time
- removeAssignment(UUID id) — remove an assignment
- listAssignments(UUID tenantId, UUID employeeId) — list all assignments for an employee
- Non-primary site scan flagging: when a clock event is at a non-primary site, flag in DailyAttendance.anomalyDetails
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| POST | /api/v1/qrcontr/site-assignments |
AssignEmployeeToSiteRequest |
ApiResponse<SiteAssignmentResponse> |
HR (P2) |
| GET | /api/v1/qrcontr/employees/{employeeId}/site-assignments |
— | ApiResponse<List<SiteAssignmentResponse>> |
HR, Manager, Employee (own) |
| DELETE | /api/v1/qrcontr/site-assignments/{id} |
— | ApiResponse<Void> |
HR (P2) |
DTOs¶
AssignEmployeeToSiteRequest:
- employeeId (UUID)
- siteId (UUID)
- isPrimary (boolean)
- effectiveFrom (LocalDate)
- effectiveTo (LocalDate, nullable)
SiteAssignmentResponse:
- All entity fields + siteName (String)
Validation Rules¶
- At most one primary site per employee at a time
- Site must exist and be active
effectiveFrommust not be null- If
effectiveTois provided, it must be aftereffectiveFrom
Multi-Tenant Considerations¶
All queries filter by tenant_id. Cross-tenant site assignments are impossible by design (site IDs are tenant-scoped).
Frontend Scope¶
Components¶
SiteAssignmentPanel:
- On site detail page, list assigned employees with primary badge
- Add employee picker to assign new employees
EmployeeSiteList:
- On employee detail page, show assigned sites with primary indicator
UI States¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | Skeleton rows | — |
| Empty | No assignments | "Aucun employe assigne a ce site." |
| Error | Inline error | "Une erreur est survenue. Reessayez." |
| Offline | Cached data, actions disabled | "Disponible en ligne uniquement" |
| Success | Row added with animation | "Employe assigne au site." |
Responsive Behavior¶
- 360px: List view, full-width assignment cards
- 768px: Same layout, with inline add
- 1024px+: Table view with bulk assignment (Enterprise V2 placeholder)
Acceptance Criteria¶
AC-001: Assign employee to primary site
Given I am HR
When I assign employee "KONE Aminata" to site "Siege Abidjan" as primary
Then the assignment is saved and the employee's primary site is "Siege Abidjan"
AC-002: Non-primary site scan flagging
Given employee "KONE Aminata" has primary site "Siege Abidjan"
When she scans the QR code at "Entrepot Yopougon" (a non-primary site)
Then the clock event is recorded normally but flagged as a non-primary site scan
OHADA & Regulatory Rules¶
None specific to site assignment.
Standards & Conventions¶
docs/standards/java-spring-guidelines.mddocs/standards/api-guidelines.mddocs/standards/database-guidelines.md
Testing Requirements¶
Unit Tests¶
- Primary uniqueness enforcement: assigning a second primary replaces the first
- Non-primary flagging logic: clock event at non-primary site sets anomaly flag
- Validation: effectiveTo before effectiveFrom rejected, missing effectiveFrom rejected
Integration Tests¶
- POST site-assignment — 201 with correct response
- GET employee site-assignments — returns all assignments with site names
- DELETE site-assignment — 200, assignment removed
- Primary site constraint: second primary replaces first
- Tenant isolation: assignments from tenant A not visible to tenant B
What QA Will Validate¶
- Assign employee to site, verify primary badge displays
- Scan at non-primary site, verify flagging in attendance records
- Verify employee detail page shows assigned sites
Out of Scope¶
- Geofencing (V2)
- Device binding (V2)
- Bulk assignment (Enterprise V2)
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend components render correctly at 360px, 768px, 1024px
- [ ] All 5 UI states implemented (loading, empty, error, offline, success)
- [ ] French micro-copy matches design spec
- [ ] All acceptance criteria pass manually
- [ ] Unit tests and integration tests passing
- [ ] Multi-tenant isolation verified
- [ ] No TypeScript
any, no Java raw types - [ ] API responses follow standard envelope format
- [ ] Audit trail entries for assignment changes
- [ ]
./gradlew testis green - [ ]
./gradlew spotlessApplypasses