Aller au contenu

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 overrides
  • existsByCountryCodeAndHolidayDate(String countryCode, LocalDate date) -> boolean
  • CompanyNonWorkingDayRepository.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

  • year query parameter is required for holidays endpoint
  • countryCode defaults to "CI" if not provided
  • dayDate required for non-working day creation
  • name required, 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_id is nullable. Platform-level holidays have tenant_id = NULL. Tenant overrides have tenant_id set.
  • Company non-working days: always scoped by tenant_id
  • isHoliday() and isNonWorkingDay() 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 editable
  • HolidayList.tsx — List of public holidays for the selected year (read-only)
  • NonWorkingDayList.tsx — List of company non-working days with delete action
  • NonWorkingDayForm.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 listeners
  • docs/standards/api-guidelines.md — ApiResponse envelope, query parameters
  • docs/standards/database-guidelines.md — Multi-tenant table design, nullable tenant_id for platform data
  • docs/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