Story QR-005: Public Holidays + Company Non-Working Days¶
@assignee: @m-kouassi
Module: qr-attendance Slice: QR-T1-004 Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 5 Depends on: QR-001 Can develop concurrently with: QR-002, QR-003, QR-004 Merge order: Before QR-009 (dashboard needs holidays for no-show detection) Estimated complexity: S PRD User Stories: N/A — supports US-QR-010 (dashboard), US-QR-011 (reports), US-QR-012 (overtime)
Objective¶
Provide the QR Attendance module with a public holiday calendar (CI holidays pre-seeded by the platform) and allow HR to manage company-specific non-working days. Both public holidays and company non-working days are used downstream to determine whether attendance is expected on a given date: on holidays and non-working days, employees are not flagged absent, and any scans classify as SUNDAY_HOLIDAY overtime.
Backend Scope¶
Flyway Migrations¶
V001_009__attendance_public_holiday.sql:
CREATE TABLE attendance_public_holiday (
id UUID PRIMARY KEY,
country_code VARCHAR(3) NOT NULL DEFAULT 'CI',
year INTEGER NOT NULL,
holiday_date DATE NOT NULL,
name_fr VARCHAR(200) NOT NULL,
holiday_code VARCHAR(50),
is_fixed BOOLEAN NOT NULL DEFAULT true,
source VARCHAR(20) NOT NULL DEFAULT 'PLATFORM',
tenant_id UUID,
synced_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(country_code, year, holiday_date, COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'))
);
CREATE INDEX idx_holiday_country_year ON attendance_public_holiday(country_code, year, holiday_date);
V001_010__attendance_company_non_working_day.sql:
CREATE TABLE attendance_company_non_working_day (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
day_date DATE NOT NULL,
name VARCHAR(200) NOT NULL,
is_recurring BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, day_date)
);
CREATE INDEX idx_company_nwd_tenant ON attendance_company_non_working_day(tenant_id, day_date);
V001_011__attendance_ci_holidays_seed.sql (seed data):
-- CI public holidays 2026-2027 (fixed dates)
INSERT INTO attendance_public_holiday (id, country_code, year, holiday_date, name_fr, holiday_code, is_fixed, source) VALUES
(gen_random_uuid(), 'CI', 2026, '2026-01-01', 'Jour de l''An', 'NEW_YEAR', true, 'PLATFORM'),
(gen_random_uuid(), 'CI', 2026, '2026-05-01', 'Fete du Travail', 'LABOUR_DAY', true, 'PLATFORM'),
(gen_random_uuid(), 'CI', 2026, '2026-08-07', 'Fete nationale', 'INDEPENDENCE_DAY', true, 'PLATFORM'),
(gen_random_uuid(), 'CI', 2026, '2026-11-15', 'Journee nationale de la Paix', 'PEACE_DAY', true, 'PLATFORM'),
(gen_random_uuid(), 'CI', 2026, '2026-12-25', 'Noel', 'CHRISTMAS', true, 'PLATFORM'),
(gen_random_uuid(), 'CI', 2026, '2026-11-01', 'Toussaint', 'ALL_SAINTS', true, 'PLATFORM'),
(gen_random_uuid(), 'CI', 2026, '2026-08-15', 'Assomption', 'ASSUMPTION', true, 'PLATFORM');
-- Moveable Islamic holidays for 2026 (approximate -- to be confirmed annually)
-- Tabaski, Maouloud, Laylat al-Qadr, etc. -- dates updated by platform admin
Entities & Records¶
AttendancePublicHoliday (shared — platform-level with optional tenant override):
| Field | Type | Constraints |
|---|---|---|
| id | UUID | PK |
| countryCode | String | NOT NULL, default "CI", max 3 |
| year | int | NOT NULL |
| holidayDate | LocalDate | NOT NULL |
| nameFr | String | NOT NULL, max 200 |
| holidayCode | String | Nullable, max 50 (e.g., "NEW_YEAR", "LABOUR_DAY") |
| isFixed | boolean | NOT NULL, default true |
| source | String | NOT NULL, "PLATFORM" or "TENANT_OVERRIDE" |
| tenantId | UUID | Nullable (null = platform-level, set = tenant override) |
| syncedAt | OffsetDateTime | NOT NULL |
CompanyNonWorkingDay (tenant DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID | PK |
| tenantId | UUID | NOT NULL |
| dayDate | LocalDate | NOT NULL, UNIQUE with tenantId |
| name | String | NOT NULL, max 200 |
| isRecurring | boolean | NOT NULL, default false |
| createdAt | OffsetDateTime | NOT NULL |
DTOs:
public record HolidayResponse(
UUID id,
String countryCode,
int year,
LocalDate holidayDate,
String nameFr,
String holidayCode,
boolean isFixed,
String source
) {}
public record NonWorkingDayResponse(
UUID id,
LocalDate dayDate,
String name,
boolean isRecurring,
OffsetDateTime createdAt
) {}
public record CreateNonWorkingDayRequest(
@NotNull LocalDate dayDate,
@NotBlank @Size(max = 200) String name,
boolean isRecurring
) {}
Repository Layer¶
AttendancePublicHolidayRepository.java:findByCountryCodeAndYear(String countryCode, int year) -> List<AttendancePublicHoliday>— Returns platform-level holidays (tenantId IS NULL)findByCountryCodeAndYearAndTenantId(String countryCode, int year, UUID tenantId) -> List<AttendancePublicHoliday>— Returns tenant overridesexistsByCountryCodeAndHolidayDate(String countryCode, LocalDate date) -> booleanCompanyNonWorkingDayRepository.java:findByTenantIdAndYear(UUID tenantId, int year) -> List<CompanyNonWorkingDay>— Custom query filtering by EXTRACT(YEAR FROM day_date)findByTenantIdAndDayDate(UUID tenantId, LocalDate date) -> Optional<CompanyNonWorkingDay>existsByTenantIdAndDayDate(UUID tenantId, LocalDate date) -> boolean
Service Layer¶
HolidayService.java:
- listHolidays(String countryCode, int year) -> List<HolidayResponse> — Returns all public holidays for the given country and year (platform-level)
- isHoliday(UUID tenantId, LocalDate date) -> boolean — Checks if date is a public holiday (platform or tenant override)
- isNonWorkingDay(UUID tenantId, LocalDate date) -> boolean — Combines public holidays + company non-working days. Returns true if date matches either.
HolidayEventListener.java:
- Listens to PublicHolidayUpdated from ABSMGT module (Spring Modulith Application Event)
- Refreshes local holiday cache when absence management module updates holidays
CompanyNonWorkingDayService.java:
- createNonWorkingDay(UUID tenantId, CreateNonWorkingDayRequest) -> NonWorkingDayResponse — Creates company non-working day. If isRecurring=true, the day applies to the same date every year. Publishes audit event.
- listNonWorkingDays(UUID tenantId, int year) -> List<NonWorkingDayResponse> — Returns all company non-working days for year (including recurring days from any year that match)
- deleteNonWorkingDay(UUID tenantId, UUID id) -> void — Deletes a company non-working day. Publishes audit event.
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| GET | /api/v1/qrcontr/holidays |
?year=2026&countryCode=CI | ApiResponse
|
HR, Manager |
| POST | /api/v1/qrcontr/company-non-working-days |
CreateNonWorkingDayRequest | ApiResponse |
HR (P2) |
| GET | /api/v1/qrcontr/company-non-working-days |
?year=2026 | ApiResponse
|
HR, Manager |
| DELETE | /api/v1/qrcontr/company-non-working-days/{id} |
— | ApiResponse |
HR (P2) |
Validation Rules¶
yearquery parameter is required for holidays endpointcountryCodedefaults to "CI" if not provideddayDaterequired for non-working day creationnamerequired, max 200 chars- Duplicate date per tenant for non-working days -> 409 error
- Cannot create a non-working day on a date that is already a public holiday (informational warning, not a blocker)
Multi-Tenant Considerations¶
- Public holidays:
tenant_idis nullable. Platform-level holidays havetenant_id = NULL. Tenant overrides havetenant_idset. - Company non-working days: always scoped by
tenant_id isHoliday()andisNonWorkingDay()combine both sources for a given tenant- All queries for company non-working days MUST filter by
tenant_id
Frontend Scope¶
Pages & Routes¶
/qrcontr/holidays— Holiday calendar + company non-working days (HR)
Components¶
HolidayCalendarPage.tsx— Year view with public holidays highlighted, company non-working days editableHolidayList.tsx— List of public holidays for the selected year (read-only)NonWorkingDayList.tsx— List of company non-working days with delete actionNonWorkingDayForm.tsx— Date picker + name field + recurring toggle
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | Calendar skeleton | — |
| Empty | Calendar with no custom days, public holidays shown | "Aucun jour non travaille specifique a l'entreprise." |
| Error | Inline error | "Une erreur est survenue. Reessayez." |
| Offline | View cached holidays, edit disabled | "Disponible en ligne uniquement" |
| Success | Day highlighted on calendar | "Jour non travaille ajoute." |
Responsive Behavior¶
- 360px (mobile): Monthly calendar view, scrollable. Non-working day form as bottom sheet.
- 768px (tablet): Monthly view with side panel for details and non-working day form
- 1024px+ (desktop): Full year overview, inline editing for non-working days
Interactions¶
- Year navigation: "< 2025 | 2026 | 2027 >" arrows to switch year
- Public holidays: Highlighted on calendar with color badge. Tap to see name. Read-only.
- Company non-working days: Different color badge. Tap to see name + delete option.
- Add non-working day: FAB (mobile) or "Ajouter" button (desktop) -> form with date picker, name, recurring toggle
- Recurring toggle: Label "Recurrent chaque annee" with switch control
- Delete confirmation: "Supprimer ce jour non travaille ?" with "Annuler" + "Supprimer" buttons
Acceptance Criteria¶
AC-001: Public holiday calendar display
Given I am HR viewing the holiday calendar
When the page loads for year 2026 and country CI
Then I see all pre-loaded public holidays for Cote d'Ivoire with their French names and dates
AC-002: Company non-working day management
Given I am HR
When I add a company-specific non-working day with name "Anniversaire de l'entreprise" on date 2026-06-15
Then the day is saved and treated identically to a public holiday for attendance purposes
And I can mark it as recurring (annual) or one-time
OHADA & Regulatory Rules¶
- FR-QR-080: CI public holiday calendar maintained by ALTARYS, updated annually.
- FR-QR-082: On holidays, no attendance expected, employees not flagged absent. If employee scans, hours classify as SUNDAY_HOLIDAY overtime.
- Islamic holidays (Tabaski, Maouloud) are moveable — dates change yearly and are updated by platform admin.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— Entity records, service layer, event listenersdocs/standards/api-guidelines.md— ApiResponse envelope, query parametersdocs/standards/database-guidelines.md— Multi-tenant table design, nullable tenant_id for platform datadocs/standards/react-typescript-guidelines.md— Component structure, calendar patterns
Testing Requirements¶
Unit Tests¶
- HolidayService.isHoliday: returns true for platform holiday date
- HolidayService.isHoliday: returns true for tenant override date
- HolidayService.isNonWorkingDay: returns true for company non-working day
- HolidayService.isNonWorkingDay: returns true for public holiday
- HolidayService.isNonWorkingDay: returns false for regular working day
- Recurring non-working day matches same date across years
Integration Tests¶
- GET /holidays?year=2026&countryCode=CI -> returns seeded CI holidays
- POST /company-non-working-days -> creates non-working day
- POST /company-non-working-days with duplicate date -> 409
- GET /company-non-working-days?year=2026 -> returns all for year including recurring
- DELETE /company-non-working-days/{id} -> deletes non-working day
- Tenant isolation: tenant A's non-working days not visible to tenant B
What QA Will Validate¶
- Holiday calendar displays correctly on all breakpoints (360px, 768px, 1024px)
- CI public holidays shown for 2026 with correct French names
- Year navigation works
- Add company non-working day with date and name
- Recurring toggle works
- Delete non-working day with confirmation
- Empty state shows guidance message
- Offline state shows cached data, blocks edits
Out of Scope¶
- Overtime classification on holidays (QR-018)
- No-show detection using holidays (QR-009)
- Multi-country holiday support beyond CI (V2)
- Editing public holiday dates (managed by platform admin, not tenant HR)
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
- [ ] CI 2026-2027 holiday seed data loaded
- [ ] No TypeScript
any, no Java raw types - [ ] API responses follow standard envelope format
- [ ] Audit trail entries for company non-working day changes