diff --git a/prisma/migrations/20260808081709_absences/migration.sql b/prisma/migrations/20260808081709_absences/migration.sql new file mode 100644 index 0000000..9aedb27 --- /dev/null +++ b/prisma/migrations/20260808081709_absences/migration.sql @@ -0,0 +1,182 @@ +-- CreateEnum +CREATE TYPE "TimeOffStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'CANCELLED', 'EXPIRED'); + +-- CreateEnum +CREATE TYPE "CounterType" AS ENUM ('PAID_LEAVE', 'RTT', 'COMPENSATORY_REST', 'MODULATION', 'OVERTIME'); + +-- CreateEnum +CREATE TYPE "LedgerKind" AS ENUM ('ACCRUAL', 'TAKEN', 'ADJUSTMENT', 'CARRY_OVER', 'EXPIRY', 'REGULARISATION'); + +-- CreateEnum +CREATE TYPE "LedgerUnit" AS ENUM ('DAY', 'HOUR'); + +-- CreateEnum +CREATE TYPE "LedgerSource" AS ENUM ('TIMEOFF', 'PAY_PERIOD', 'MANUAL', 'SYSTEM'); + +-- CreateEnum +CREATE TYPE "PolicyStatus" AS ENUM ('ACTIVE', 'ARCHIVED'); + +-- CreateTable +CREATE TABLE "TimeOff" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "membershipId" TEXT NOT NULL, + "userContractId" TEXT, + "absenceTypeId" TEXT NOT NULL, + "startDate" DATE NOT NULL, + "startHalfDay" BOOLEAN NOT NULL DEFAULT false, + "endDate" DATE NOT NULL, + "endHalfDay" BOOLEAN NOT NULL DEFAULT false, + "countedDays" DECIMAL(6,2), + "status" "TimeOffStatus" NOT NULL DEFAULT 'PENDING', + "comment" TEXT, + "requestedBy" TEXT NOT NULL, + "requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "decidedBy" TEXT, + "decidedAt" TIMESTAMP(3), + "decisionComment" TEXT, + "justificationDocumentId" TEXT, + + CONSTRAINT "TimeOff_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Counter" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "membershipId" TEXT NOT NULL, + "counterType" "CounterType" NOT NULL, + "acquisitionPeriodStart" DATE NOT NULL, + "acquisitionPeriodEnd" DATE NOT NULL, + + CONSTRAINT "Counter_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LedgerOperation" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "counterId" TEXT NOT NULL, + "kind" "LedgerKind" NOT NULL, + "quantity" DECIMAL(10,4) NOT NULL, + "unit" "LedgerUnit" NOT NULL, + "effectiveDate" DATE NOT NULL, + "sourceType" "LedgerSource" NOT NULL, + "sourceId" TEXT, + "reason" TEXT, + "reversesId" TEXT, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LedgerOperation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RttPolicy" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "daysPerYear" DECIMAL(5,2) NOT NULL, + "periodStart" TEXT NOT NULL, + "autoRenew" BOOLEAN NOT NULL DEFAULT true, + "status" "PolicyStatus" NOT NULL DEFAULT 'ACTIVE', + + CONSTRAINT "RttPolicy_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RttPolicyAssignment" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "rttPolicyId" TEXT NOT NULL, + "membershipId" TEXT NOT NULL, + + CONSTRAINT "RttPolicyAssignment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LeaveNotice" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "membershipId" TEXT NOT NULL, + "timeOffId" TEXT, + "returnDate" DATE NOT NULL, + "dueDate" DATE NOT NULL, + "content" TEXT NOT NULL, + "deliveredAt" TIMESTAMP(3), + "deliveryProof" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LeaveNotice_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TimeOff_accountId_idx" ON "TimeOff"("accountId"); + +-- CreateIndex +CREATE INDEX "TimeOff_membershipId_startDate_idx" ON "TimeOff"("membershipId", "startDate"); + +-- CreateIndex +CREATE INDEX "TimeOff_accountId_status_idx" ON "TimeOff"("accountId", "status"); + +-- CreateIndex +CREATE INDEX "Counter_accountId_idx" ON "Counter"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Counter_membershipId_counterType_acquisitionPeriodStart_key" ON "Counter"("membershipId", "counterType", "acquisitionPeriodStart"); + +-- CreateIndex +CREATE UNIQUE INDEX "LedgerOperation_reversesId_key" ON "LedgerOperation"("reversesId"); + +-- CreateIndex +CREATE INDEX "LedgerOperation_accountId_idx" ON "LedgerOperation"("accountId"); + +-- CreateIndex +CREATE INDEX "LedgerOperation_counterId_effectiveDate_idx" ON "LedgerOperation"("counterId", "effectiveDate"); + +-- CreateIndex +CREATE INDEX "RttPolicy_accountId_idx" ON "RttPolicy"("accountId"); + +-- CreateIndex +CREATE INDEX "RttPolicyAssignment_accountId_idx" ON "RttPolicyAssignment"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RttPolicyAssignment_rttPolicyId_membershipId_key" ON "RttPolicyAssignment"("rttPolicyId", "membershipId"); + +-- CreateIndex +CREATE INDEX "LeaveNotice_accountId_idx" ON "LeaveNotice"("accountId"); + +-- CreateIndex +CREATE INDEX "LeaveNotice_membershipId_idx" ON "LeaveNotice"("membershipId"); + +-- AddForeignKey +ALTER TABLE "TimeOff" ADD CONSTRAINT "TimeOff_absenceTypeId_fkey" FOREIGN KEY ("absenceTypeId") REFERENCES "AbsenceType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerOperation" ADD CONSTRAINT "LedgerOperation_counterId_fkey" FOREIGN KEY ("counterId") REFERENCES "Counter"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RttPolicyAssignment" ADD CONSTRAINT "RttPolicyAssignment_rttPolicyId_fkey" FOREIGN KEY ("rttPolicyId") REFERENCES "RttPolicy"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Isolation : toute table portant accountId doit porter sa politique. +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['TimeOff','Counter','LedgerOperation','RttPolicy','RttPolicyAssignment','LeaveNotice'] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t); + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())', t); + EXECUTE format( + 'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())', t); + END LOOP; +END $$; + +-- Le registre est append-only : une correction s'écrit — contre-passation puis +-- nouvelle écriture — elle ne se réécrit pas. La règle est en base et non +-- seulement dans l'application, parce qu'une règle applicative finit par être +-- contournée par un script de reprise. +CREATE TRIGGER ledger_operation_append_only + BEFORE UPDATE OR DELETE ON "LedgerOperation" + FOR EACH ROW EXECUTE FUNCTION planflow_deny_write(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d977051..b13ebd5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -374,6 +374,7 @@ model AbsenceType { archivedAt DateTime? account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + timeOffs TimeOff[] @@unique([accountId, code]) @@index([accountId]) @@ -834,3 +835,180 @@ model PayrollExport { @@index([accountId, periodStart]) @@index([accountId]) } + +// ============================================================================ +// Absences et compteurs — PLAN.md §4.5 et §7, WP-06 +// ============================================================================ + +/// Demande d'absence. +/// +/// `endDate` porte le **dernier jour d'absence**, jamais la date de reprise. +/// La confusion est la plus fréquente du domaine : elle décompte un jour de +/// trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au solde, +/// des mois plus tard. +model TimeOff { + id String @id @default(cuid()) + accountId String + membershipId String + userContractId String? + absenceTypeId String + startDate DateTime @db.Date + startHalfDay Boolean @default(false) + endDate DateTime @db.Date + endHalfDay Boolean @default(false) + /// Décompte figé à la décision : le calendrier de l'établissement peut + /// changer après coup, le droit acquis ne doit pas bouger avec lui. + countedDays Decimal? @db.Decimal(6, 2) + status TimeOffStatus @default(PENDING) + comment String? + + requestedBy String + requestedAt DateTime @default(now()) + decidedBy String? + decidedAt DateTime? + decisionComment String? + justificationDocumentId String? + + absenceType AbsenceType @relation(fields: [absenceTypeId], references: [id]) + + @@index([accountId]) + @@index([membershipId, startDate]) + @@index([accountId, status]) +} + +enum TimeOffStatus { + PENDING + ACCEPTED + DECLINED + CANCELLED + EXPIRED +} + +/// Compteur d'un salarié sur une période d'acquisition. +/// +/// Ne porte **aucun solde** : le solde est la somme des écritures. Un solde +/// stocké se désynchronise, et la désynchronisation ne se voit qu'au moment où +/// un salarié conteste. +model Counter { + id String @id @default(cuid()) + accountId String + membershipId String + counterType CounterType + acquisitionPeriodStart DateTime @db.Date + acquisitionPeriodEnd DateTime @db.Date + + operations LedgerOperation[] + + @@unique([membershipId, counterType, acquisitionPeriodStart]) + @@index([accountId]) +} + +enum CounterType { + PAID_LEAVE + RTT + COMPENSATORY_REST + MODULATION + OVERTIME +} + +/// Écriture de compteur — **append-only**, imposé par un trigger. +/// +/// Une correction s'écrit : contre-passation portant `reversesId`, puis +/// nouvelle écriture. Elle ne se réécrit pas. +model LedgerOperation { + id String @id @default(cuid()) + accountId String + counterId String + kind LedgerKind + /// Signé : positif acquiert, négatif consomme. + quantity Decimal @db.Decimal(10, 4) + unit LedgerUnit + effectiveDate DateTime @db.Date + sourceType LedgerSource + sourceId String? + reason String? + reversesId String? @unique + createdBy String? + createdAt DateTime @default(now()) + + counter Counter @relation(fields: [counterId], references: [id], onDelete: Cascade) + + @@index([accountId]) + @@index([counterId, effectiveDate]) +} + +enum LedgerKind { + ACCRUAL + TAKEN + ADJUSTMENT + CARRY_OVER + EXPIRY + REGULARISATION +} + +enum LedgerUnit { + DAY + HOUR +} + +enum LedgerSource { + TIMEOFF + PAY_PERIOD + MANUAL + SYSTEM +} + +model RttPolicy { + id String @id @default(cuid()) + accountId String + name String + daysPerYear Decimal @db.Decimal(5, 2) + /// Début de période au format « MM-JJ ». + periodStart String + autoRenew Boolean @default(true) + status PolicyStatus @default(ACTIVE) + + assignments RttPolicyAssignment[] + + @@index([accountId]) +} + +enum PolicyStatus { + ACTIVE + ARCHIVED +} + +model RttPolicyAssignment { + id String @id @default(cuid()) + accountId String + rttPolicyId String + membershipId String + + policy RttPolicy @relation(fields: [rttPolicyId], references: [id], onDelete: Cascade) + + @@unique([rttPolicyId, membershipId]) + @@index([accountId]) +} + +/// Information due au salarié à son retour d'arrêt — PLAN.md §12.3. +/// +/// Obligation **active** et non un affichage : l'employeur doit informer des +/// droits acquis et de la date limite de prise dans le mois suivant la reprise. +/// La preuve de remise est ce qui rend l'obligation opposable. +model LeaveNotice { + id String @id @default(cuid()) + accountId String + membershipId String + timeOffId String? + /// Date de reprise qui déclenche le délai d'un mois. + returnDate DateTime @db.Date + dueDate DateTime @db.Date + /// Contenu remis, figé : il fait foi. + content String + deliveredAt DateTime? + deliveryProof String? + createdAt DateTime @default(now()) + + @@index([accountId]) + @@index([membershipId]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index ca73bd0..548ba1f 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -20,6 +20,7 @@ import { IDCC_1517_PARAMETERS, IDCC_1517_PROVENANCE, } from '../src/domain/compliance/idcc1517'; +import { frenchHolidays } from '../src/domain/absences/holidays'; import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements'; import { evaluateSchedule } from '../src/server/compliance/evaluate'; import { withTenant } from '../src/server/tenant'; @@ -465,55 +466,51 @@ async function main() { console.log(` ${IDCC_1517_PROVENANCE.length} paramètres tracés`); console.log('→ Jours fériés et dimanches autorisés'); - // Jours fériés légaux français, hors Alsace-Moselle. - const holidays2026: Array<[string, string]> = [ - ['2026-01-01', 'Jour de l’an'], - ['2026-04-06', 'Lundi de Pâques'], - ['2026-05-01', 'Fête du travail'], - ['2026-05-08', 'Victoire 1945'], - ['2026-05-14', 'Ascension'], - ['2026-05-25', 'Lundi de Pentecôte'], - ['2026-07-14', 'Fête nationale'], - ['2026-08-15', 'Assomption'], - ['2026-11-01', 'Toussaint'], - ['2026-11-11', 'Armistice 1918'], - ['2026-12-25', 'Noël'], - ]; + // Calculés, pas listés : une table écrite à la main est juste l'année où on + // l'écrit. Un férié manquant se décompte comme un jour de congé, et le + // salarié perd un jour sans que personne ne le voie. + const currentYear = new Date().getUTCFullYear(); + const years = [currentYear - 1, currentYear, currentYear + 1, currentYear + 2]; + let holidayCount = 0; for (const location of locations) { - for (const [date, name] of holidays2026) { - await prisma.holiday.upsert({ - where: { - locationId_localDate: { - locationId: location.id, - localDate: new Date(`${date}T00:00:00Z`), + for (const year of years) { + for (const holiday of frenchHolidays(year)) { + await prisma.holiday.upsert({ + where: { + locationId_localDate: { + locationId: location.id, + localDate: new Date(`${holiday.isoDate}T00:00:00Z`), + }, }, - }, - update: { name }, - create: { - accountId: account.id, - locationId: location.id, - localDate: new Date(`${date}T00:00:00Z`), - name, - // Les trois jours chômés garantis par la convention sont choisis par - // l'employeur : ils ne sont pas devinés ici. - isPaidOff: date === '2026-05-01', - }, - }); + update: { name: holiday.name }, + create: { + accountId: account.id, + locationId: location.id, + localDate: new Date(`${holiday.isoDate}T00:00:00Z`), + name: holiday.name, + // Seul le 1er mai est chômé de droit. Les trois jours garantis par + // la convention sont choisis par l'employeur : ils ne se devinent + // pas ici. + isPaidOff: holiday.isoDate.slice(5) === '05-01', + }, + }); + holidayCount += 1; + } } - // Douze dimanches du maire, liste arrêtée avant le 31 décembre pour - // l'année suivante (L3132-26). Valeurs de démonstration. + // Douze dimanches du maire au plus, liste arrêtée avant le 31 décembre + // pour l'année suivante (L3132-26). Valeurs de démonstration. const sundays = [ - '2026-01-11', - '2026-06-28', - '2026-07-05', - '2026-08-30', - '2026-09-06', - '2026-11-29', - '2026-12-06', - '2026-12-13', - '2026-12-20', + `${currentYear}-01-11`, + `${currentYear}-06-28`, + `${currentYear}-07-05`, + `${currentYear}-08-30`, + `${currentYear}-09-06`, + `${currentYear}-11-29`, + `${currentYear}-12-06`, + `${currentYear}-12-13`, + `${currentYear}-12-20`, ]; for (const date of sundays) { await prisma.authorisedSunday.upsert({ @@ -533,7 +530,7 @@ async function main() { }); } } - console.log(` ${holidays2026.length} jours fériés par établissement`); + console.log(` ${holidayCount} jours fériés sur ${years.length} années`); console.log('→ Durées de conservation'); const retention = [ @@ -567,6 +564,130 @@ async function main() { console.log(` ${retention.length} politiques`); + + console.log('→ Types d’absence'); + // `silaeCode` reste **null** : les codes AB-* du dossier sont connus, leur + // signification ne l'est pas. Les associer ici reviendrait à deviner à quel + // type d'absence correspond AB-300, ce qui produirait une paie fausse. + const absenceTypes = [ + { code: 'CP', name: 'Congés payés', colorKey: 'cp', isPaid: true, social: false, notice: 30 }, + { code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7 }, + { code: 'MAL', name: 'Arrêt maladie', colorKey: 'maladie', isPaid: false, social: true, notice: null }, + { code: 'SS', name: 'Congé sans solde', colorKey: 'sans-solde', isPaid: false, social: false, notice: 15 }, + { code: 'RC', name: 'Repos compensateur', colorKey: 'rtt', isPaid: true, social: false, notice: 7 }, + ] as const; + + for (const type of absenceTypes) { + await prisma.absenceType.upsert({ + where: { accountId_code: { accountId: account.id, code: type.code } }, + update: { name: type.name, colorKey: type.colorKey }, + create: { + accountId: account.id, + code: type.code, + name: type.name, + colorKey: type.colorKey, + isPaid: type.isPaid, + countsAsWorkTime: false, + affectsPaidLeaveAccrual: !type.social, + isSocialSecurity: type.social, + requiresJustification: type.social, + minNoticeDays: type.notice, + }, + }); + } + console.log(` ${absenceTypes.length} types`); + + console.log('→ Absences de démonstration'); + const cpType = await prisma.absenceType.findFirst({ + where: { accountId: account.id, code: 'CP' }, + }); + const malType = await prisma.absenceType.findFirst({ + where: { accountId: account.id, code: 'MAL' }, + }); + const demoMembers = await prisma.membership.findMany({ + where: { accountId: account.id, employeeNumber: { in: ['E0005', 'E0007'] } }, + }); + + const today = new Date(); + const iso = (offsetDays: number) => + new Date(today.getTime() + offsetDays * 86_400_000) + .toISOString() + .slice(0, 10); + + const demoAbsences = [ + // Acceptée, en cours : elle doit apparaître sur la grille de planning. + { member: 'E0005', type: cpType?.id, from: iso(1), to: iso(5), status: 'ACCEPTED' as const, days: 5 }, + // En attente : elle doit apparaître dans la file à traiter. + { member: 'E0007', type: malType?.id, from: iso(-2), to: iso(2), status: 'PENDING' as const, days: 4 }, + ]; + + for (const absence of demoAbsences) { + const membership = demoMembers.find( + (member) => member.employeeNumber === absence.member, + ); + if (!membership || !absence.type) continue; + + const existing = await prisma.timeOff.findFirst({ + where: { membershipId: membership.id }, + }); + if (existing) continue; + + const created = await prisma.timeOff.create({ + data: { + accountId: account.id, + membershipId: membership.id, + absenceTypeId: absence.type, + startDate: new Date(`${absence.from}T00:00:00Z`), + endDate: new Date(`${absence.to}T00:00:00Z`), + countedDays: absence.days, + status: absence.status, + requestedBy: membership.id, + ...(absence.status === 'ACCEPTED' + ? { decidedBy: membership.id, decidedAt: new Date() } + : {}), + }, + }); + + if (absence.status !== 'ACCEPTED') continue; + + // L'acceptation écrit au registre : le solde bouge parce qu'une écriture a + // été posée, jamais parce qu'un champ a été décrémenté. + const year = today.getUTCMonth() >= 5 ? today.getUTCFullYear() : today.getUTCFullYear() - 1; + const counter = await prisma.counter.create({ + data: { + accountId: account.id, + membershipId: membership.id, + counterType: 'PAID_LEAVE', + acquisitionPeriodStart: new Date(`${year}-06-01T00:00:00Z`), + acquisitionPeriodEnd: new Date(`${year + 1}-05-31T00:00:00Z`), + }, + }); + await prisma.ledgerOperation.createMany({ + data: [ + { + accountId: account.id, + counterId: counter.id, + kind: 'ACCRUAL', + quantity: 25, + unit: 'DAY', + effectiveDate: new Date(`${year}-06-01T00:00:00Z`), + sourceType: 'SYSTEM', + }, + { + accountId: account.id, + counterId: counter.id, + kind: 'TAKEN', + quantity: -absence.days, + unit: 'DAY', + effectiveDate: new Date(`${absence.from}T00:00:00Z`), + sourceType: 'TIMEOFF', + sourceId: created.id, + }, + ], + }); + } + console.log(` ${demoAbsences.length} absences`); + console.log('→ Correspondances Silae'); // Semées **non confirmées**, y compris quand le code se lit dans son libellé // (`EV-HDimanche`). Proposer n'est pas confirmer : seul le gestionnaire de diff --git a/src/app/(app)/conges/page.tsx b/src/app/(app)/conges/page.tsx index 0171e7c..87773d0 100644 --- a/src/app/(app)/conges/page.tsx +++ b/src/app/(app)/conges/page.tsx @@ -1,148 +1,329 @@ +import Link from 'next/link'; + +import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons'; +import { RequestForm } from '@/components/absences/RequestForm'; import { PageBody, PageHeader } from '@/components/shell/PageHeader'; -import { Badge } from '@/components/ui/Badge'; -import { Button } from '@/components/ui/Button'; -import { Card, CardHeader } from '@/components/ui/Card'; +import { Badge, type Tone } from '@/components/ui/Badge'; +import { datesBetween, isoWeekday } from '@/domain/absences/count'; +import { monthOf, parseMonthParam } from '@/domain/planning/month'; import { cx } from '@/lib/cx'; -import { - CALENDAR_ABSENCES, - MONTH_LABEL, - MONTH_LENGTH, - MONTH_OFFSET, - PENDING_REQUESTS, -} from '@/lib/demo/conges'; +import { getAbsenceBoard, statusLabel } from '@/server/absences/queries'; export const metadata = { title: 'Calendrier des absences · PlanFlow' }; -const WEEKDAYS = [ - 'Lundi', - 'Mardi', - 'Mercredi', - 'Jeudi', - 'Vendredi', - 'Samedi', - 'Dimanche', -]; +interface PageProps { + searchParams: Promise<{ mois?: string }>; +} -const KIND_STYLES: Record = { - cp: 'bg-info-soft text-info-soft-ink border-info', - rtt: 'bg-accent-soft text-accent-soft-ink border-accent', - maladie: 'bg-surface-3 text-ink-2 border-line-3', - 'sans-solde': 'bg-surface-2 text-ink-2 border-line-3', - attente: 'bg-warn-soft text-warn-soft-ink border-warn', +const COUNTER_LABELS: Record = { + PAID_LEAVE: 'Congés payés', + RTT: 'RTT', + COMPENSATORY_REST: 'Repos compensateur', + MODULATION: 'Modulation', + OVERTIME: 'Heures supplémentaires', }; -export default function CongesPage() { - const cells = Array.from({ length: 42 }, (_, index) => { - const dayNumber = index - MONTH_OFFSET + 1; - const inMonth = dayNumber >= 1 && dayNumber <= MONTH_LENGTH; - const items = inMonth - ? CALENDAR_ABSENCES.filter( - (absence) => dayNumber >= absence.from && dayNumber <= absence.to, - ) - : []; - return { index, dayNumber, inMonth, items, weekend: index % 7 > 4 }; - }); +const STATUS_TONES: Record = { + PENDING: 'warn', + ACCEPTED: 'ok', + DECLINED: 'danger', + CANCELLED: 'neutral', + EXPIRED: 'neutral', +}; + +export default async function CongesPage({ searchParams }: PageProps) { + const params = await searchParams; + const month = parseMonthParam(params.mois) ?? monthOf(new Date()); + const board = await getAbsenceBoard(month); + + const href = (mois: string) => `/conges?mois=${mois}`; return ( 1 ? 's' : ''} sur le mois`} actions={ <> - - + + ← Mois précédent + + + Mois suivant → + } /> - -
- {WEEKDAYS.map((day) => ( -
- {day} -
- ))} -
-
- {cells.map((cell) => ( -
- - {cell.inMonth ? cell.dayNumber : ''} - -
    - {cell.items.slice(0, 3).map((absence) => ( -
  • - {absence.who.split(' ')[0]} · {absence.type} -
  • - ))} - {cell.items.length > 3 ? ( -
  • - +{cell.items.length - 3} -
  • - ) : null} -
-
- ))} -
-
+ - - - {PENDING_REQUESTS.length}} - /> -
    - {PENDING_REQUESTS.map((request) => ( -
  • - - - {request.who} · {request.type} +
    +

    + Demandes en attente + {board.pending.length > 0 ? ` · ${board.pending.length}` : ''} +

    + + {board.pending.length === 0 ? ( +

    + Aucune demande à traiter. +

    + ) : ( +
      + {board.pending.map((request) => ( +
    • +
      +

      + {request.name} · {request.typeLabel} +

      +

      + du {frenchDate(request.startDate)} au{' '} + {frenchDate(request.endDate)} ·{' '} + {request.days} jour + {request.days > 1 ? 's' : ''} décompté + {request.days > 1 ? 's' : ''} +

      + {request.comment ? ( +

      + « {request.comment} » +

      + ) : null} +
      + + {board.canDecide ? ( + + ) : ( + + )} +
    • + ))} +
    + )} +
    + + + +
    +

    + Absences du mois +

    + {board.inMonth.length === 0 ? ( +

    + Aucune absence sur cette période. +

    + ) : ( +
      + {board.inMonth.map((request) => ( +
    • + + {statusLabel(request.status)} + + {request.name} + {request.typeLabel} + + {frenchDate(request.startDate)} → {frenchDate(request.endDate)} - - {request.note} + + {request.days} j - - - {request.range} - - {request.days} - - - - -
    • - ))} -
    - + {request.status === 'ACCEPTED' ? ( + + ) : null} +
  • + ))} +
+ )} + + +
+

+ Compteurs +

+ {board.counters.length === 0 ? ( +

+ Aucun compteur ouvert. Un compteur se crée à la première écriture — + acceptation d’un congé ou ajustement manuel. +

+ ) : ( + + + + + + + + + + + + {board.counters.map((counter) => ( + + + + + + + + ))} + +
+ Salarié + + Compteur + + Acquis + + Pris + + Solde +
{counter.name} + {COUNTER_LABELS[counter.counterType] ?? counter.counterType} + + {counter.accrued} + + {counter.taken} + + {counter.balance} +
+ )} +

+ Le solde est la somme des écritures du registre, jamais une valeur + stockée : un solde stocké se désynchronise, et la désynchronisation ne + se voit qu’au moment où un salarié conteste. +

+
); } + +/** Calendrier du mois, une colonne par jour. */ +function MonthCalendar({ + board, +}: { + board: Awaited>; +}) { + const accepted = board.inMonth.filter( + (request) => request.status === 'ACCEPTED' || request.status === 'PENDING', + ); + const people = [...new Set(accepted.map((request) => request.membershipId))]; + + if (people.length === 0) return null; + + return ( +
+ + + + + + {board.days.map((date) => ( + + ))} + + + + {people.map((membershipId) => { + const own = accepted.filter( + (request) => request.membershipId === membershipId, + ); + const covered = new Map(); + for (const request of own) { + for (const date of datesBetween( + request.startDate, + request.endDate, + )) { + covered.set(date, request); + } + } + + return ( + + + {board.days.map((date) => { + const request = covered.get(date); + return ( + + ); + })} + + ); + })} + +
+ Calendrier des absences, {board.label} +
+ Salarié + = 6 + ? 'bg-surface-3 text-ink-2' + : 'text-ink-1', + )} + > + {Number(date.slice(8, 10))} +
+ {own[0]?.name ?? ''} + = 6 && 'bg-surface-2', + )} + title={ + request + ? `${request.typeLabel} — ${statusLabel(request.status)}` + : undefined + } + > + {request ? ( + + + {request.typeLabel} le {date} + + + ) : null} +
+
+ ); +} + +function frenchDate(isoDate: string): string { + return isoDate.split('-').reverse().join('/'); +} diff --git a/src/components/absences/DecisionButtons.tsx b/src/components/absences/DecisionButtons.tsx new file mode 100644 index 0000000..381ad22 --- /dev/null +++ b/src/components/absences/DecisionButtons.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Button } from '@/components/ui/Button'; +import { + cancelTimeOffAction, + decideTimeOffAction, + type AbsenceActionState, +} from '@/server/absences/actions'; + +const empty: AbsenceActionState = {}; + +/** Accepter ou refuser une demande en attente. */ +export function DecisionButtons({ timeOffId }: { timeOffId: string }) { + const [state, formAction, pending] = useActionState( + decideTimeOffAction, + empty, + ); + + return ( +
+ + + {state.error ? ( + + {state.error} + + ) : null} + + +
+ ); +} + +/** + * Annulation d'une absence. + * + * Sur une absence acceptée, la prise est contre-passée et non effacée : le + * solde revient au même chiffre, et l'histoire reste lisible. + */ +export function CancelButton({ timeOffId }: { timeOffId: string }) { + const [state, formAction, pending] = useActionState( + cancelTimeOffAction, + empty, + ); + + return ( +
+ + {state.error ? ( + + {state.error} + + ) : null} + +
+ ); +} diff --git a/src/components/absences/RequestForm.tsx b/src/components/absences/RequestForm.tsx new file mode 100644 index 0000000..3bede0b --- /dev/null +++ b/src/components/absences/RequestForm.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Button } from '@/components/ui/Button'; +import { + requestTimeOffAction, + type AbsenceActionState, +} from '@/server/absences/actions'; + +const empty: AbsenceActionState = {}; + +/** + * Demande d'absence. + * + * Le libellé du champ de fin dit **« dernier jour d'absence »** et non « date + * de fin ». C'est la confusion la plus fréquente du domaine : elle décompte un + * jour de trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au + * solde, des mois plus tard. Un libellé exact coûte moins qu'une régularisation. + */ +export function RequestForm({ + absenceTypes, + people, + ownMembershipId, +}: { + absenceTypes: Array<{ id: string; code: string; name: string }>; + people: Array<{ membershipId: string; name: string }>; + ownMembershipId: string; +}) { + const [state, formAction, pending] = useActionState( + requestTimeOffAction, + empty, + ); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + +

+ Le dernier jour d’absence est la veille de la reprise. Les jours fériés + compris dans la période ne sont pas décomptés — inutile de scinder la + demande. +

+ + {state.error ? ( +

+ {state.error} +

+ ) : null} + {state.warning ? ( +

{state.warning}

+ ) : null} + {state.ok && !state.warning ? ( +

Demande enregistrée.

+ ) : null} +
+ ); +} + +function Field({ + label, + htmlFor, + children, +}: { + label: string; + htmlFor: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/components/planning/WeekGrid.tsx b/src/components/planning/WeekGrid.tsx index 723cb57..9646fb6 100644 --- a/src/components/planning/WeekGrid.tsx +++ b/src/components/planning/WeekGrid.tsx @@ -1,3 +1,4 @@ +import { AbsenceBar, type AbsenceKind } from '@/components/planning/AbsenceBar'; import { CounterStrip } from '@/components/planning/CounterStrip'; import { ShiftChip } from '@/components/planning/ShiftChip'; import { computeWeekCounters } from '@/domain/counters/week'; @@ -182,6 +183,18 @@ function Row({
+ {row.absences.map((absence, position) => ( + + ))} + {days.map((day, index) => { const shifts = row.days[index] ?? []; return ( @@ -191,6 +204,13 @@ function Row({ className={cx( 'group/cell flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0', index > 4 && 'bg-surface-2', + // La bande d'absence flotte au-dessus : la case lui laisse la + // place plutôt que de la faire recouvrir un créneau. + row.absences.some( + (absence) => + index >= absence.startDay && + index < absence.startDay + absence.span, + ) && 'pt-7', )} > {shifts.map((shift) => ( @@ -218,3 +238,16 @@ function Row({
); } + +/** + * Teinte de la bande d'absence. + * + * Reprend la clé de couleur du type d'absence configuré par le client, avec un + * repli neutre : une teinte inconnue vaut mieux affichée en gris qu'absente. + */ +function absenceKind(colorKey: string): AbsenceKind { + const known: AbsenceKind[] = ['cp', 'rtt', 'maladie', 'sans-solde', 'attente']; + return known.includes(colorKey as AbsenceKind) + ? (colorKey as AbsenceKind) + : 'sans-solde'; +} diff --git a/src/domain/absences/count.ts b/src/domain/absences/count.ts new file mode 100644 index 0000000..2fc9ceb --- /dev/null +++ b/src/domain/absences/count.ts @@ -0,0 +1,179 @@ +/** + * Décompte des jours d'absence — PLAN.md §7.2. + * + * Trois règles gouvernent ce module, et chacune correspond à une erreur qui + * coûte cher : + * + * 1. **`endDate` est le dernier jour d'absence, pas la date de reprise.** La + * confusion est la plus fréquente du domaine : elle décompte un jour de + * trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au + * solde, des mois plus tard. + * 2. **Un jour férié dans un congé ne se décompte pas** — et l'utilisateur n'a + * pas à scinder sa demande pour l'obtenir. Exiger la scission, c'est + * déplacer sur lui la charge d'un calcul que l'outil sait faire. + * 3. **Les jours non travaillés ne se décomptent pas.** Un temps partiel qui + * ne travaille jamais le mercredi ne consomme pas de congé ce jour-là. + */ + +export interface AbsenceRange { + /** Premier jour d'absence, date civile `AAAA-MM-JJ`. */ + startDate: string; + /** **Dernier jour d'absence**, jamais la date de reprise. */ + endDate: string; + startHalfDay?: boolean; + endHalfDay?: boolean; +} + +export interface CountingContext { + /** Jours fériés de l'établissement, dates civiles. */ + holidays: string[]; + /** + * Jours de la semaine travaillés selon le rythme du contrat. + * 1 = lundi … 7 = dimanche. Par défaut du lundi au vendredi. + */ + workingWeekdays?: number[]; + /** + * Décompte en jours **ouvrables** (lundi–samedi) ou **ouvrés** + * (lundi–vendredi). Le droit du travail acquiert en ouvrables ; beaucoup + * d'entreprises décomptent en ouvrés. Le choix est un paramètre, pas une + * constante — se tromper fausse tous les soldes de la même façon. + */ + basis?: 'OUVRABLES' | 'OUVRES'; +} + +const DEFAULT_WORKING_WEEKDAYS = [1, 2, 3, 4, 5]; +const DAY_MS = 86_400_000; + +/** Jour ISO de la semaine : 1 = lundi … 7 = dimanche. */ +export function isoWeekday(isoDate: string): number { + const day = new Date(`${isoDate}T00:00:00Z`).getUTCDay(); + return day === 0 ? 7 : day; +} + +/** Les dates civiles couvertes par la période, bornes incluses. */ +export function datesBetween(startDate: string, endDate: string): string[] { + const start = new Date(`${startDate}T00:00:00Z`).getTime(); + const end = new Date(`${endDate}T00:00:00Z`).getTime(); + if (end < start) return []; + + const dates: string[] = []; + for (let time = start; time <= end; time += DAY_MS) { + dates.push(new Date(time).toISOString().slice(0, 10)); + } + return dates; +} + +export interface CountedDay { + isoDate: string; + /** 1 pour une journée pleine, 0,5 pour une demi-journée, 0 si non décompté. */ + quantity: number; + reason: 'WORKED' | 'HOLIDAY' | 'NOT_WORKED' | 'HALF_DAY'; +} + +/** + * Détaille le décompte jour par jour. + * + * Le détail est rendu, pas seulement le total : quand un salarié conteste son + * solde, « 8 jours » ne se discute pas, la liste des jours retenus si. + */ +export function countAbsenceDays( + range: AbsenceRange, + context: CountingContext, +): { total: number; days: CountedDay[] } { + const holidays = new Set(context.holidays); + const basis = context.basis ?? 'OUVRABLES'; + const contractDays = new Set( + context.workingWeekdays ?? DEFAULT_WORKING_WEEKDAYS, + ); + // Les jours ouvrables incluent le samedi ; les ouvrés s'arrêtent au vendredi. + const basisMax = basis === 'OUVRABLES' ? 6 : 5; + + const dates = datesBetween(range.startDate, range.endDate); + const days: CountedDay[] = dates.map((isoDate) => { + const weekday = isoWeekday(isoDate); + + if (holidays.has(isoDate)) { + return { isoDate, quantity: 0, reason: 'HOLIDAY' as const }; + } + if (weekday > basisMax || !contractDays.has(weekday)) { + return { isoDate, quantity: 0, reason: 'NOT_WORKED' as const }; + } + return { isoDate, quantity: 1, reason: 'WORKED' as const }; + }); + + // Les demi-journées ne s'appliquent qu'aux bornes, et seulement si elles + // sont effectivement décomptées : une demi-journée sur un jour férié ne + // retire rien de plus. + const first = days[0]; + const last = days[days.length - 1]; + + if (range.startHalfDay && first && first.quantity === 1) { + first.quantity = 0.5; + first.reason = 'HALF_DAY'; + } + if (range.endHalfDay && last && last.quantity === 1) { + // Une absence d'un seul jour marquée demi-journée des deux côtés reste une + // demi-journée, pas zéro. + last.quantity = last === first ? 0.5 : 0.5; + last.reason = 'HALF_DAY'; + } + + const total = days.reduce((sum, day) => sum + day.quantity, 0); + return { total, days }; +} + +/** Total seul, quand le détail n'est pas nécessaire. */ +export function absenceDayCount( + range: AbsenceRange, + context: CountingContext, +): number { + return countAbsenceDays(range, context).total; +} + +export interface OverlapCandidate { + id: string; + startDate: string; + endDate: string; +} + +/** + * Absences acceptées qui recouvrent la période demandée. + * + * Bornes **inclusives des deux côtés**, parce que `endDate` est un jour + * d'absence. Une comparaison exclusive laisserait passer deux congés qui se + * touchent le même jour. + */ +export function findAbsenceOverlaps( + range: Pick, + existing: OverlapCandidate[], + ignoreId?: string, +): OverlapCandidate[] { + return existing.filter( + (candidate) => + candidate.id !== ignoreId && + candidate.startDate <= range.endDate && + candidate.endDate >= range.startDate, + ); +} + +/** + * Une demande porte-t-elle sur le passé ? + * + * Informatif : une régularisation a posteriori est légitime — un arrêt maladie + * se déclare toujours après coup. C'est l'écran qui décide d'avertir, pas le + * domaine qui interdit. + */ +export function isRetroactive(range: AbsenceRange, today: string): boolean { + return range.startDate < today; +} + +/** Le préavis conventionnel est-il respecté ? */ +export function respectsNotice( + range: AbsenceRange, + today: string, + minNoticeDays: number | null, +): boolean { + if (minNoticeDays === null || minNoticeDays <= 0) return true; + const days = datesBetween(today, range.startDate).length - 1; + return days >= minNoticeDays; +} diff --git a/src/domain/absences/holidays.ts b/src/domain/absences/holidays.ts new file mode 100644 index 0000000..1406c06 --- /dev/null +++ b/src/domain/absences/holidays.ts @@ -0,0 +1,83 @@ +/** + * Jours fériés légaux français. + * + * Calculés plutôt que listés : une table écrite à la main est juste l'année où + * on l'écrit et fausse dès la suivante. Un jour férié manquant se décompte + * comme un jour de congé — le salarié perd un jour, et personne ne le voit. + * + * Ne couvre pas l'Alsace-Moselle, qui compte deux jours de plus (Vendredi saint + * et 26 décembre). Ils s'ajoutent par établissement, comme le reste : le + * référentiel `Holiday` est porté par la `Location`, précisément pour qu'une + * chaîne puisse ouvrir un magasin de l'autre côté de la limite. + */ + +export interface FrenchHoliday { + isoDate: string; + name: string; +} + +const DAY_MS = 86_400_000; + +/** + * Dimanche de Pâques, algorithme grégorien anonyme. + * + * Trois des onze jours fériés en dépendent : lundi de Pâques, Ascension et + * lundi de Pentecôte. Se tromper de date décale trois jours dans l'année. + */ +export function easterSunday(year: number): Date { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + + return new Date(Date.UTC(year, month - 1, day)); +} + +function iso(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function shift(date: Date, days: number): Date { + return new Date(date.getTime() + days * DAY_MS); +} + +/** Les onze jours fériés légaux d'une année, triés. */ +export function frenchHolidays(year: number): FrenchHoliday[] { + const easter = easterSunday(year); + + return [ + { isoDate: `${year}-01-01`, name: 'Jour de l’an' }, + { isoDate: iso(shift(easter, 1)), name: 'Lundi de Pâques' }, + { isoDate: `${year}-05-01`, name: 'Fête du travail' }, + { isoDate: `${year}-05-08`, name: 'Victoire 1945' }, + { isoDate: iso(shift(easter, 39)), name: 'Ascension' }, + { isoDate: iso(shift(easter, 50)), name: 'Lundi de Pentecôte' }, + { isoDate: `${year}-07-14`, name: 'Fête nationale' }, + { isoDate: `${year}-08-15`, name: 'Assomption' }, + { isoDate: `${year}-11-01`, name: 'Toussaint' }, + { isoDate: `${year}-11-11`, name: 'Armistice 1918' }, + { isoDate: `${year}-12-25`, name: 'Noël' }, + ].sort((a, b) => a.isoDate.localeCompare(b.isoDate)); +} + +/** + * Le 1er mai est le seul jour férié **chômé de droit**. + * + * Les autres ne le sont que par convention ou usage : l'IDCC 1517 en garantit + * trois par an, choisis par l'employeur. Le supposer pour les onze donnerait + * des soldes de congés faux dans le sens favorable au salarié, ce qui n'est pas + * plus acceptable que l'inverse. + */ +export function isLabourDay(isoDate: string): boolean { + return isoDate.slice(5) === '05-01'; +} diff --git a/src/domain/absences/ledger.ts b/src/domain/absences/ledger.ts new file mode 100644 index 0000000..0f6037a --- /dev/null +++ b/src/domain/absences/ledger.ts @@ -0,0 +1,145 @@ +/** + * Registre des compteurs — PLAN.md §7.1. + * + * `LedgerOperation` est la source de vérité, et le solde est la **somme** des + * écritures. Aucun solde n'est stocké : un solde stocké se désynchronise, et la + * désynchronisation ne se voit qu'au moment où un salarié conteste. + * + * Corollaire : **aucune écriture n'est jamais modifiée ni supprimée.** Une + * correction s'écrit — contre-passation puis nouvelle écriture — elle ne se + * réécrit pas. Un trigger PostgreSQL l'impose, parce qu'une règle appliquée + * seulement dans l'application finit par être contournée par un script. + */ + +export type LedgerKind = + | 'ACCRUAL' + | 'TAKEN' + | 'ADJUSTMENT' + | 'CARRY_OVER' + | 'EXPIRY' + | 'REGULARISATION'; + +export type LedgerUnit = 'DAY' | 'HOUR'; + +export interface LedgerEntry { + kind: LedgerKind; + /** **Signé** : positif acquiert, négatif consomme. */ + quantity: number; + unit: LedgerUnit; + effectiveDate: string; +} + +/** Le solde est la somme, jamais une valeur stockée. */ +export function balanceOf(entries: LedgerEntry[]): number { + return round(entries.reduce((sum, entry) => sum + entry.quantity, 0)); +} + +/** Solde à une date donnée, écritures postérieures exclues. */ +export function balanceAt(entries: LedgerEntry[], isoDate: string): number { + return balanceOf(entries.filter((entry) => entry.effectiveDate <= isoDate)); +} + +export interface CounterView { + /** Acquis sur la période. */ + accrued: number; + /** Pris, en valeur absolue. */ + taken: number; + /** Solde courant. */ + balance: number; + /** Solde projeté en fin de période d'acquisition. */ + projected: number; + unit: LedgerUnit; +} + +/** + * Vue d'un compteur, courant et prévisionnel. + * + * Le prévisionnel projette les acquisitions **restant à courir** sur la période. + * C'est ce que le salarié veut savoir avant de poser ses congés d'été : « ce + * que j'aurai », pas « ce que j'ai ». + */ +export function counterView( + entries: LedgerEntry[], + remainingAccrual: number, + unit: LedgerUnit = 'DAY', +): CounterView { + const accrued = round( + entries + .filter((entry) => entry.quantity > 0) + .reduce((sum, entry) => sum + entry.quantity, 0), + ); + const taken = round( + Math.abs( + entries + .filter((entry) => entry.kind === 'TAKEN') + .reduce((sum, entry) => sum + entry.quantity, 0), + ), + ); + const balance = balanceOf(entries); + + return { + accrued, + taken, + balance, + projected: round(balance + remainingAccrual), + unit, + }; +} + +/** + * Acquisition de congés payés — PLAN.md §12.3. + * + * 2,5 jours ouvrables par mois de travail effectif. Pendant un arrêt maladie + * **non professionnelle**, l'acquisition continue à 2 jours par mois, dans la + * limite de 24 jours par an : c'est le droit issu de la réforme de 2024, et + * l'oublier prive le salarié d'un droit acquis. + * + * Les valeurs viennent des paramètres, pas d'ici. + */ +export interface AccrualParameters { + /** Jours acquis par mois de travail effectif. */ + perWorkedMonth: number; + /** Jours acquis par mois d'arrêt maladie non professionnelle. */ + perSickMonth: number; + /** Plafond annuel d'acquisition pendant maladie. */ + sickAnnualCap: number; +} + +export const PAID_LEAVE_ACCRUAL: AccrualParameters = { + perWorkedMonth: 2.5, + perSickMonth: 2, + sickAnnualCap: 24, +}; + +export function accrualForMonth( + worked: boolean, + sickMonths: number, + parameters: AccrualParameters = PAID_LEAVE_ACCRUAL, +): number { + if (worked) return parameters.perWorkedMonth; + + const alreadyAccrued = sickMonths * parameters.perSickMonth; + const remaining = parameters.sickAnnualCap - alreadyAccrued; + return round(Math.max(0, Math.min(parameters.perSickMonth, remaining))); +} + +/** + * Écriture inverse d'une écriture existante. + * + * Même quantité, signe opposé. La date d'effet est celle de la correction, pas + * celle de l'écriture d'origine : antidater masquerait la correction dans les + * soldes déjà communiqués. + */ +export function reversalOf(entry: LedgerEntry, onDate: string): LedgerEntry { + return { + kind: 'REGULARISATION', + quantity: -entry.quantity, + unit: entry.unit, + effectiveDate: onDate, + }; +} + +/** Quatre décimales : la précision du ledger, pas celle de l'affichage. */ +function round(value: number): number { + return Math.round(value * 10_000) / 10_000; +} diff --git a/src/domain/planning/board.ts b/src/domain/planning/board.ts index 81bcaea..00b889f 100644 --- a/src/domain/planning/board.ts +++ b/src/domain/planning/board.ts @@ -34,6 +34,15 @@ export interface BoardShift { note: string | null; } +export interface BoardAbsence { + label: string; + colorKey: string; + /** Index de colonne du premier jour visible, 0 = lundi. */ + startDay: number; + /** Nombre de jours couverts dans la semaine affichée. */ + span: number; +} + export interface BoardRow { /** `null` sur la ligne des besoins non couverts. */ membershipId: string | null; @@ -44,6 +53,8 @@ export interface BoardRow { counters: WeekCounters; /** Sept cases, lundi → dimanche. */ days: BoardShift[][]; + /** Absences acceptées recouvrant la semaine affichée. */ + absences: BoardAbsence[]; unassigned: boolean; } @@ -56,6 +67,15 @@ export interface RowPerson { contractMinutes: number; } +export interface AbsenceInput { + membershipId: string; + startDate: string; + /** Dernier jour d'absence, pas la date de reprise. */ + endDate: string; + label: string; + colorKey: string; +} + /** * Range les créneaux d'une semaine en lignes × sept colonnes. * @@ -70,6 +90,7 @@ export function buildRows( weekDates: string[], timeZone: string, isPublished: boolean, + absences: AbsenceInput[] = [], ): { rows: BoardRow[]; unassignedRow: BoardRow | null } { const columnOf = new Map(weekDates.map((date, index) => [date, index])); @@ -128,9 +149,34 @@ export function buildRows( } for (const column of unassignedDays) column.sort(byStartTime); + const first = weekDates[0] ?? ''; + const last = weekDates[6] ?? ''; + const rows = people.map((person) => { const days = byMember.get(person.membershipId) ?? emptyDays(); const worked = days.filter((column) => column.length > 0).length; + + // L'absence est **rognée** aux bornes de la semaine affichée : une absence + // de trois semaines doit apparaître entière sur chacune, pas déborder. + const rowAbsences = absences + .filter( + (absence) => + absence.membershipId === person.membershipId && + absence.startDate <= last && + absence.endDate >= first, + ) + .map((absence) => { + const startDay = Math.max(0, weekDates.indexOf(absence.startDate)); + const endIndex = weekDates.indexOf(absence.endDate); + const endDay = endIndex === -1 ? 6 : endIndex; + return { + label: absence.label, + colorKey: absence.colorKey, + startDay: absence.startDate < first ? 0 : startDay, + span: endDay - (absence.startDate < first ? 0 : startDay) + 1, + }; + }); + return { membershipId: person.membershipId, firstName: person.firstName, @@ -139,12 +185,18 @@ export function buildRows( forfaitJours: person.forfaitJours, unassigned: false, days, + absences: rowAbsences, counters: { contractMinutes: person.forfaitJours ? 0 : person.contractMinutes, plannedMinutes: minutesByMember.get(person.membershipId) ?? 0, - // Les absences arrivent au lot suivant ; le compteur existe déjà pour - // que la formule d'écart ne change pas quand elles se brancheront. - absenceMinutes: 0, + // Les absences comptent dans l'atteinte du contrat : une semaine + // entièrement en congés ne doit pas apparaître en sous-réalisation. + // Leur valorisation en minutes vient du contrat, pas du planning. + absenceMinutes: absenceMinutesOf( + rowAbsences, + person.contractMinutes, + person.forfaitJours, + ), sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0, restDays: 7 - worked, }, @@ -160,6 +212,7 @@ export function buildRows( forfaitJours: false, unassigned: true, days: unassignedDays, + absences: [], counters: { contractMinutes: 0, plannedMinutes: unassignedDays @@ -175,6 +228,24 @@ export function buildRows( return { rows, unassignedRow }; } +/** + * Durée créditée par une absence, en minutes. + * + * Proportionnelle au contrat sur une base de cinq jours ouvrés : un salarié à + * 35 h absent trois jours est crédité de 21 h. Le calcul reste ici, dans la + * mise en forme, parce qu'il ne sert qu'à l'écart affiché — la paie, elle, part + * du décompte en jours figé à la décision. + */ +function absenceMinutesOf( + absences: BoardAbsence[], + contractMinutes: number, + forfaitJours: boolean, +): number { + if (forfaitJours || contractMinutes <= 0) return 0; + const days = absences.reduce((total, absence) => total + absence.span, 0); + return Math.round((contractMinutes / 5) * Math.min(days, 5)); +} + function byStartTime(a: BoardShift, b: BoardShift): number { return a.time.localeCompare(b.time); } diff --git a/src/lib/demo/conges.ts b/src/lib/demo/conges.ts deleted file mode 100644 index 1585abf..0000000 --- a/src/lib/demo/conges.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { AbsenceKind } from '@/components/planning/AbsenceBar'; - -export interface DemoCalendarAbsence { - who: string; - type: string; - kind: AbsenceKind; - /** Jours du mois, inclusifs. */ - from: number; - to: number; -} - -export const MONTH_LABEL = 'Août 2026'; - -/** Le 1er août 2026 tombe un samedi : la grille démarre le lundi 27 juillet. */ -export const MONTH_OFFSET = 5; -export const MONTH_LENGTH = 31; - -export const CALENDAR_ABSENCES: DemoCalendarAbsence[] = [ - { who: 'Nour Zaidi', type: 'CP', kind: 'cp', from: 10, to: 14 }, - { who: 'Malik Ouedraogo', type: 'Maladie', kind: 'maladie', from: 13, to: 14 }, - { who: 'Rémi Chartier', type: 'RTT', kind: 'rtt', from: 18, to: 19 }, - { who: 'Elsa Tavares', type: 'CP', kind: 'cp', from: 24, to: 28 }, - { who: 'Inès Bakhti', type: 'Sans solde', kind: 'sans-solde', from: 21, to: 21 }, - { who: 'Alix Perron', type: 'Maladie', kind: 'maladie', from: 5, to: 6 }, - { who: 'Chloé Vasseur', type: 'CP', kind: 'cp', from: 26, to: 28 }, -]; - -export interface DemoPendingRequest { - who: string; - type: string; - range: string; - days: string; - note: string; -} - -export const PENDING_REQUESTS: DemoPendingRequest[] = [ - { - who: 'Nour Zaidi', - type: 'Congés payés', - range: '10 – 14 août', - days: '5 j', - note: 'Solde restant après validation : 9,5 j', - }, - { - who: 'Rémi Chartier', - type: 'RTT', - range: '18 – 19 août', - days: '2 j', - note: 'Chevauche un jour de forte affluence', - }, - { - who: 'Alix Perron', - type: 'Sans solde', - range: '31 août', - days: '1 j', - note: 'Aucune contrainte de planning', - }, -]; diff --git a/src/server/absences/actions.ts b/src/server/absences/actions.ts new file mode 100644 index 0000000..f220e50 --- /dev/null +++ b/src/server/absences/actions.ts @@ -0,0 +1,586 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +import { AuthorizationError, can } from '@/domain/access/authorize'; +import { + countAbsenceDays, + findAbsenceOverlaps, + respectsNotice, +} from '@/domain/absences/count'; +import { recordAudit } from '@/server/audit'; +import { mutate } from '@/server/context'; +import type { ScopedClient } from '@/server/tenant'; + +/** + * Écritures d'absences — PLAN.md §7.1 et §7.2. + * + * Deux invariants gouvernent ce fichier : + * + * 1. **Le décompte est figé à la décision.** Le calendrier de l'établissement + * peut changer après coup ; un droit acquis ne doit pas bouger avec lui. + * 2. **Le registre ne se réécrit pas.** Annuler une absence acceptée n'efface + * pas la prise : cela écrit une contre-passation. Le solde revient au même + * chiffre, l'histoire reste lisible. + */ + +export interface AbsenceActionState { + error?: string; + ok?: boolean; + warning?: string; +} + +class ValidationError extends Error {} + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +const requestInput = z.object({ + membershipId: z.string().min(1), + absenceTypeId: z.string().min(1), + startDate: z.string().regex(ISO_DATE, 'Date de début invalide'), + endDate: z.string().regex(ISO_DATE, 'Date de fin invalide'), + startHalfDay: z.boolean(), + endHalfDay: z.boolean(), + comment: z.string().trim().max(500).optional(), +}); + +/** Contexte de décompte d'un salarié : jours fériés et rythme contractuel. */ +async function countingContextFor(db: ScopedClient, membershipId: string) { + const contract = await db.userContract.findFirst({ + where: { membershipId, status: 'ACTIVE' }, + orderBy: { startDate: 'desc' }, + }); + + const holidays = contract + ? await db.holiday.findMany({ + where: { locationId: contract.locationId }, + select: { localDate: true }, + }) + : []; + + return { + contract, + context: { + holidays: holidays.map((holiday) => + holiday.localDate.toISOString().slice(0, 10), + ), + // Le rythme réel se déduira du planning au lot suivant ; à défaut, la + // semaine standard, qui est le cas de la quasi-totalité des contrats. + basis: 'OUVRABLES' as const, + workingWeekdays: [1, 2, 3, 4, 5, 6], + }, + }; +} + +export async function requestTimeOffAction( + _previous: AbsenceActionState, + formData: FormData, +): Promise { + const parsed = requestInput.safeParse({ + membershipId: formData.get('membershipId'), + absenceTypeId: formData.get('absenceTypeId'), + startDate: formData.get('startDate'), + endDate: formData.get('endDate'), + startHalfDay: formData.get('startHalfDay') === 'on', + endHalfDay: formData.get('endHalfDay') === 'on', + comment: formData.get('comment') || undefined, + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; + } + + let warning: string | undefined; + + try { + await mutate('timeoff.request', async (db, actor) => { + // Demander pour autrui suppose la capacité de décider : un salarié ne + // pose pas les congés d'un collègue. + if ( + parsed.data.membershipId !== actor.membershipId && + !can(actor, 'timeoff.decide') + ) { + throw new AuthorizationError('timeoff.decide'); + } + + if (parsed.data.endDate < parsed.data.startDate) { + throw new ValidationError( + 'La date de fin précède la date de début. Rappel : la date de fin est le **dernier jour d’absence**, pas la date de reprise.', + ); + } + + const absenceType = await db.absenceType.findUnique({ + where: { id: parsed.data.absenceTypeId }, + }); + if (!absenceType) throw new ValidationError("Type d'absence inconnu."); + + const existing = await db.timeOff.findMany({ + where: { + membershipId: parsed.data.membershipId, + status: { in: ['PENDING', 'ACCEPTED'] }, + }, + select: { id: true, startDate: true, endDate: true }, + }); + + const overlaps = findAbsenceOverlaps( + { startDate: parsed.data.startDate, endDate: parsed.data.endDate }, + existing.map((entry) => ({ + id: entry.id, + startDate: entry.startDate.toISOString().slice(0, 10), + endDate: entry.endDate.toISOString().slice(0, 10), + })), + ); + if (overlaps.length > 0) { + throw new ValidationError( + 'Une absence couvre déjà tout ou partie de cette période.', + ); + } + + const today = new Date().toISOString().slice(0, 10); + if ( + !respectsNotice( + { startDate: parsed.data.startDate, endDate: parsed.data.endDate }, + today, + absenceType.minNoticeDays, + ) + ) { + // Avertir plutôt qu'interdire : un manager peut accepter une demande + // hors délai, et le préavis n'est pas une règle de sécurité. + warning = `Préavis de ${absenceType.minNoticeDays} jours non respecté.`; + } + + const { contract, context } = await countingContextFor( + db, + parsed.data.membershipId, + ); + const counted = countAbsenceDays( + { + startDate: parsed.data.startDate, + endDate: parsed.data.endDate, + startHalfDay: parsed.data.startHalfDay, + endHalfDay: parsed.data.endHalfDay, + }, + context, + ); + + if (counted.total === 0) { + throw new ValidationError( + 'Cette période ne contient aucun jour décomptable : jours fériés ou non travaillés uniquement.', + ); + } + + // Sans jour férié enregistré sur la période, le décompte les compte comme + // travaillés — et le salarié perd un jour sans que rien ne le signale. + // Mieux vaut le dire que laisser croire à un décompte complet. + const year = parsed.data.startDate.slice(0, 4); + const knownHolidays = context.holidays.filter((date) => + date.startsWith(year), + ); + if (knownHolidays.length === 0) { + warning = [ + warning, + `Aucun jour férié n'est enregistré pour ${year} : le décompte les compte comme travaillés.`, + ] + .filter(Boolean) + .join(' '); + } + + const created = await db.timeOff.create({ + data: { + membershipId: parsed.data.membershipId, + userContractId: contract?.id ?? null, + absenceTypeId: parsed.data.absenceTypeId, + startDate: new Date(`${parsed.data.startDate}T00:00:00Z`), + endDate: new Date(`${parsed.data.endDate}T00:00:00Z`), + startHalfDay: parsed.data.startHalfDay, + endHalfDay: parsed.data.endHalfDay, + countedDays: counted.total, + comment: parsed.data.comment ?? null, + requestedBy: actor.membershipId, + } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'timeoff.request', + entityType: 'TimeOff', + entityId: created.id, + after: { + membershipId: parsed.data.membershipId, + type: absenceType.code, + from: parsed.data.startDate, + to: parsed.data.endDate, + days: counted.total, + }, + }); + }); + } catch (error) { + return toState(error, "Vous n'avez pas le droit de poser cette absence."); + } + + revalidatePath('/conges'); + return warning ? { ok: true, warning } : { ok: true }; +} + +const decisionInput = z.object({ + timeOffId: z.string().min(1), + accept: z.boolean(), + comment: z.string().trim().max(500).optional(), +}); + +/** + * Accepte ou refuse une demande. + * + * L'acceptation écrit au registre : le solde bouge parce qu'une écriture a été + * posée, jamais parce qu'un champ a été décrémenté. + */ +export async function decideTimeOffAction( + _previous: AbsenceActionState, + formData: FormData, +): Promise { + const parsed = decisionInput.safeParse({ + timeOffId: formData.get('timeOffId'), + accept: formData.get('accept') === 'true', + comment: formData.get('comment') || undefined, + }); + + if (!parsed.success) return { error: 'Demande introuvable.' }; + + try { + await mutate('timeoff.decide', async (db, actor) => { + const timeOff = await db.timeOff.findUnique({ + where: { id: parsed.data.timeOffId }, + include: { absenceType: true }, + }); + if (!timeOff) throw new AuthorizationError('timeoff.decide'); + if (timeOff.status !== 'PENDING') { + throw new ValidationError('Cette demande a déjà été traitée.'); + } + + if (parsed.data.accept) { + // Le contrôle est refait en transaction : entre la demande et la + // décision, un autre congé a pu être accepté sur la même période. + const others = await db.timeOff.findMany({ + where: { + membershipId: timeOff.membershipId, + status: 'ACCEPTED', + id: { not: timeOff.id }, + }, + select: { id: true, startDate: true, endDate: true }, + }); + const overlaps = findAbsenceOverlaps( + { + startDate: timeOff.startDate.toISOString().slice(0, 10), + endDate: timeOff.endDate.toISOString().slice(0, 10), + }, + others.map((entry) => ({ + id: entry.id, + startDate: entry.startDate.toISOString().slice(0, 10), + endDate: entry.endDate.toISOString().slice(0, 10), + })), + ); + if (overlaps.length > 0) { + throw new ValidationError( + 'Une autre absence acceptée recouvre désormais cette période.', + ); + } + } + + await db.timeOff.update({ + where: { id: timeOff.id }, + data: { + status: parsed.data.accept ? 'ACCEPTED' : 'DECLINED', + decidedBy: actor.membershipId, + decidedAt: new Date(), + decisionComment: parsed.data.comment ?? null, + }, + }); + + if (parsed.data.accept) { + await writeTaken(db, timeOff, actor.membershipId); + } + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: parsed.data.accept ? 'timeoff.accept' : 'timeoff.decline', + entityType: 'TimeOff', + entityId: timeOff.id, + before: { status: timeOff.status }, + after: { + status: parsed.data.accept ? 'ACCEPTED' : 'DECLINED', + days: timeOff.countedDays?.toString() ?? null, + }, + reason: parsed.data.comment ?? null, + }); + }); + } catch (error) { + return toState(error, "Vous n'avez pas le droit de décider de cette demande."); + } + + revalidatePath('/conges'); + return { ok: true }; +} + +/** + * Annule une absence. + * + * Sur une absence acceptée, la prise est **contre-passée** et non effacée : le + * solde revient au même chiffre, et l'histoire reste lisible pour qui devra + * l'expliquer. + */ +export async function cancelTimeOffAction( + _previous: AbsenceActionState, + formData: FormData, +): Promise { + const timeOffId = String(formData.get('timeOffId') ?? ''); + if (!timeOffId) return { error: 'Demande introuvable.' }; + + try { + await mutate('timeoff.request', async (db, actor) => { + const timeOff = await db.timeOff.findUnique({ + where: { id: timeOffId }, + include: { absenceType: true }, + }); + if (!timeOff) throw new AuthorizationError('timeoff.request'); + + const isOwn = timeOff.membershipId === actor.membershipId; + if (!isOwn && !can(actor, 'timeoff.decide')) { + throw new AuthorizationError('timeoff.decide'); + } + if (timeOff.status === 'CANCELLED') { + throw new ValidationError('Cette absence est déjà annulée.'); + } + + if (timeOff.status === 'ACCEPTED') { + await reverseTaken(db, timeOff, actor.membershipId); + } + + await db.timeOff.update({ + where: { id: timeOff.id }, + data: { status: 'CANCELLED' }, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'timeoff.cancel', + entityType: 'TimeOff', + entityId: timeOff.id, + before: { status: timeOff.status }, + after: { status: 'CANCELLED' }, + }); + }); + } catch (error) { + return toState(error, "Vous n'avez pas le droit d'annuler cette absence."); + } + + revalidatePath('/conges'); + return { ok: true }; +} + +const adjustInput = z.object({ + membershipId: z.string().min(1), + counterType: z.enum([ + 'PAID_LEAVE', + 'RTT', + 'COMPENSATORY_REST', + 'MODULATION', + 'OVERTIME', + ]), + quantity: z.coerce.number().refine((value) => value !== 0, { + message: 'Un ajustement de zéro ne corrige rien.', + }), + reason: z.string().trim().min(1, 'Justification obligatoire').max(500), +}); + +/** + * Ajustement manuel d'un compteur. + * + * La justification est **obligatoire** et refusée si vide. Un ajustement sans + * motif ne vaut pas mieux qu'une absence de trace : il laisse un écart que + * personne ne saura expliquer six mois plus tard. + */ +export async function adjustCounterAction( + _previous: AbsenceActionState, + formData: FormData, +): Promise { + const parsed = adjustInput.safeParse({ + membershipId: formData.get('membershipId'), + counterType: formData.get('counterType'), + quantity: formData.get('quantity'), + reason: formData.get('reason') ?? '', + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; + } + + try { + await mutate('counters.adjust', async (db, actor) => { + const counter = await ensureCounter( + db, + parsed.data.membershipId, + parsed.data.counterType, + ); + + await db.ledgerOperation.create({ + data: { + counterId: counter.id, + kind: 'ADJUSTMENT', + quantity: parsed.data.quantity, + unit: 'DAY', + effectiveDate: new Date(new Date().toISOString().slice(0, 10)), + sourceType: 'MANUAL', + reason: parsed.data.reason, + createdBy: actor.membershipId, + } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'counter.adjust', + entityType: 'Counter', + entityId: counter.id, + after: { + quantity: parsed.data.quantity, + counterType: parsed.data.counterType, + }, + reason: parsed.data.reason, + }); + }); + } catch (error) { + return toState(error, "Vous n'avez pas le droit d'ajuster un compteur."); + } + + revalidatePath('/conges'); + return { ok: true }; +} + +/** Compteur de la période d'acquisition courante, créé au besoin. */ +async function ensureCounter( + db: ScopedClient, + membershipId: string, + counterType: + | 'PAID_LEAVE' + | 'RTT' + | 'COMPENSATORY_REST' + | 'MODULATION' + | 'OVERTIME', +) { + // Période d'acquisition des congés payés : 1er juin → 31 mai, usage + // dominant en France. Le paramétrer relève du registre §12.7. + const now = new Date(); + const year = now.getUTCMonth() >= 5 ? now.getUTCFullYear() : now.getUTCFullYear() - 1; + const start = new Date(`${year}-06-01T00:00:00Z`); + const end = new Date(`${year + 1}-05-31T00:00:00Z`); + + const existing = await db.counter.findFirst({ + where: { + membershipId, + counterType, + acquisitionPeriodStart: start, + }, + }); + if (existing) return existing; + + return db.counter.create({ + data: { + membershipId, + counterType, + acquisitionPeriodStart: start, + acquisitionPeriodEnd: end, + } as never, + }); +} + +interface TimeOffWithType { + id: string; + membershipId: string; + countedDays: { toString(): string } | null; + startDate: Date; + absenceType: { code: string; isPaid: boolean; countsAsWorkTime: boolean }; +} + +/** Écrit la prise au registre du compteur correspondant. */ +async function writeTaken( + db: ScopedClient, + timeOff: TimeOffWithType, + actorMembershipId: string, +): Promise { + const counterType = counterTypeFor(timeOff.absenceType.code); + // Une absence sans compteur — maladie, sans solde — ne consomme rien : elle + // s'affiche au planning et part en paie, sans toucher un solde de congés. + if (!counterType) return; + + const counter = await ensureCounter(db, timeOff.membershipId, counterType); + const days = Number(timeOff.countedDays?.toString() ?? '0'); + + await db.ledgerOperation.create({ + data: { + counterId: counter.id, + kind: 'TAKEN', + quantity: -days, + unit: 'DAY', + effectiveDate: timeOff.startDate, + sourceType: 'TIMEOFF', + sourceId: timeOff.id, + createdBy: actorMembershipId, + } as never, + }); +} + +/** Contre-passe la prise, sans la supprimer. */ +async function reverseTaken( + db: ScopedClient, + timeOff: TimeOffWithType, + actorMembershipId: string, +): Promise { + const original = await db.ledgerOperation.findFirst({ + where: { sourceType: 'TIMEOFF', sourceId: timeOff.id, kind: 'TAKEN' }, + }); + if (!original) return; + + const already = await db.ledgerOperation.findFirst({ + where: { reversesId: original.id }, + }); + if (already) return; + + await db.ledgerOperation.create({ + data: { + counterId: original.counterId, + kind: 'REGULARISATION', + // Signe opposé, date du jour : antidater masquerait la correction dans + // les soldes déjà communiqués. + quantity: -Number(original.quantity.toString()), + unit: original.unit, + effectiveDate: new Date(new Date().toISOString().slice(0, 10)), + sourceType: 'TIMEOFF', + sourceId: timeOff.id, + reason: 'Annulation de l’absence', + reversesId: original.id, + createdBy: actorMembershipId, + } as never, + }); +} + +/** + * Compteur consommé par un type d'absence. + * + * Volontairement restreint : seuls les congés payés et les RTT consomment un + * solde. Un arrêt maladie ne se décompte d'aucun compteur — l'y imputer + * priverait le salarié de congés auxquels il a droit. + */ +function counterTypeFor( + code: string, +): 'PAID_LEAVE' | 'RTT' | 'COMPENSATORY_REST' | null { + if (code === 'CP') return 'PAID_LEAVE'; + if (code === 'RTT') return 'RTT'; + if (code === 'RC') return 'COMPENSATORY_REST'; + return null; +} + +function toState(error: unknown, denied: string): AbsenceActionState { + if (error instanceof ValidationError) return { error: error.message }; + if (error instanceof AuthorizationError) return { error: denied }; + throw error; +} diff --git a/src/server/absences/queries.ts b/src/server/absences/queries.ts new file mode 100644 index 0000000..bad2fe4 --- /dev/null +++ b/src/server/absences/queries.ts @@ -0,0 +1,263 @@ +import { can } from '@/domain/access/authorize'; +import { counterView, type LedgerEntry } from '@/domain/absences/ledger'; +import { monthDates, monthLabel, type Month } from '@/domain/planning/month'; +import { query } from '@/server/context'; + +/** + * Lectures des absences — PLAN.md §7. + * + * Une règle de confidentialité gouverne ce module : **un manager voit qu'un + * salarié est absent, pas pourquoi il est malade** (matrice n° 9). Le motif + * médical est une donnée de santé, catégorie particulière du RGPD. Il n'est + * donc pas chargé pour qui n'a pas la capacité de le lire — un champ absent de + * la réponse ne peut pas fuiter par le HTML ni par un journal. + */ + +export interface AbsenceRequest { + id: string; + membershipId: string; + name: string; + /** Libellé du type, ou « Absence » quand le motif est masqué. */ + typeLabel: string; + colorKey: string; + isSocialSecurity: boolean; + startDate: string; + endDate: string; + startHalfDay: boolean; + endHalfDay: boolean; + days: number; + status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'CANCELLED' | 'EXPIRED'; + comment: string | null; + decisionComment: string | null; + requestedAt: Date; +} + +export interface CounterSummary { + membershipId: string; + name: string; + counterType: string; + accrued: number; + taken: number; + balance: number; + projected: number; +} + +export interface AbsenceBoard { + month: Month; + label: string; + monthParam: string; + previousParam: string; + nextParam: string; + days: string[]; + pending: AbsenceRequest[]; + inMonth: AbsenceRequest[]; + counters: CounterSummary[]; + absenceTypes: Array<{ id: string; code: string; name: string }>; + people: Array<{ membershipId: string; name: string }>; + canDecide: boolean; + canAdjust: boolean; + ownMembershipId: string; +} + +const STATUS_LABEL = { + PENDING: 'En attente', + ACCEPTED: 'Acceptée', + DECLINED: 'Refusée', + CANCELLED: 'Annulée', + EXPIRED: 'Expirée', +} as const; + +export function statusLabel(status: AbsenceRequest['status']): string { + return STATUS_LABEL[status]; +} + +export async function getAbsenceBoard(month: Month): Promise { + return query('timeoff.view_own', async (db, actor) => { + const canSeeOthers = can(actor, 'timeoff.view_others'); + const canDecide = can(actor, 'timeoff.decide'); + const canAdjust = can(actor, 'counters.adjust'); + // Le motif médical suit les documents du dossier : qui peut lire un arrêt + // de travail peut lire sa nature. + const canSeeMedical = can(actor, 'members.documents.view'); + + const dates = monthDates(month); + const first = dates[0] as string; + const last = dates[dates.length - 1] as string; + + const scope = canSeeOthers + ? {} + : { membershipId: actor.membershipId }; + + const timeOffs = await db.timeOff.findMany({ + where: { + ...scope, + OR: [ + { + startDate: { + gte: new Date(`${first}T00:00:00Z`), + lte: new Date(`${last}T00:00:00Z`), + }, + }, + { + endDate: { + gte: new Date(`${first}T00:00:00Z`), + lte: new Date(`${last}T00:00:00Z`), + }, + }, + { status: 'PENDING' }, + ], + }, + include: { absenceType: true }, + orderBy: { startDate: 'asc' }, + }); + + const memberIds = [ + ...new Set(timeOffs.map((entry) => entry.membershipId)), + actor.membershipId, + ]; + const members = await db.membership.findMany({ + where: canSeeOthers ? {} : { id: { in: memberIds } }, + include: { profile: { select: { firstName: true, lastName: true } } }, + }); + const nameById = new Map( + members.map((member) => [ + member.id, + `${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(), + ]), + ); + + const toRequest = (entry: (typeof timeOffs)[number]): AbsenceRequest => ({ + id: entry.id, + membershipId: entry.membershipId, + name: nameById.get(entry.membershipId) ?? 'Salarié', + // Un arrêt maladie s'affiche « Absence » pour qui n'a pas le droit d'en + // connaître la nature. + typeLabel: + entry.absenceType.isSocialSecurity && !canSeeMedical + ? 'Absence' + : entry.absenceType.name, + colorKey: entry.absenceType.colorKey, + isSocialSecurity: entry.absenceType.isSocialSecurity, + startDate: entry.startDate.toISOString().slice(0, 10), + endDate: entry.endDate.toISOString().slice(0, 10), + startHalfDay: entry.startHalfDay, + endHalfDay: entry.endHalfDay, + days: Number(entry.countedDays?.toString() ?? '0'), + status: entry.status, + comment: + entry.absenceType.isSocialSecurity && !canSeeMedical + ? null + : entry.comment, + decisionComment: entry.decisionComment, + requestedAt: entry.requestedAt, + }); + + const requests = timeOffs.map(toRequest); + const pending = requests.filter((entry) => entry.status === 'PENDING'); + const inMonth = requests.filter( + (entry) => entry.startDate <= last && entry.endDate >= first, + ); + + const counters = await db.counter.findMany({ + where: canSeeOthers ? {} : { membershipId: actor.membershipId }, + include: { operations: true }, + }); + + const { formatMonthParam, nextMonth, previousMonth } = await import( + '@/domain/planning/month' + ); + + const absenceTypes = await db.absenceType.findMany({ + where: { archivedAt: null }, + select: { id: true, code: true, name: true }, + orderBy: { code: 'asc' }, + }); + + return { + month, + label: monthLabel(month), + monthParam: formatMonthParam(month), + previousParam: formatMonthParam(previousMonth(month)), + nextParam: formatMonthParam(nextMonth(month)), + days: dates, + pending, + inMonth, + counters: counters.map((counter) => { + const entries: LedgerEntry[] = counter.operations.map((operation) => ({ + kind: operation.kind, + quantity: Number(operation.quantity.toString()), + unit: operation.unit, + effectiveDate: operation.effectiveDate.toISOString().slice(0, 10), + })); + // La projection des acquisitions restantes arrive avec la clôture des + // périodes de paie (WP-07) : d'ici là, le prévisionnel vaut le courant. + const view = counterView(entries, 0); + return { + membershipId: counter.membershipId, + name: nameById.get(counter.membershipId) ?? 'Salarié', + counterType: counter.counterType, + accrued: view.accrued, + taken: view.taken, + balance: view.balance, + projected: view.projected, + }; + }), + absenceTypes, + people: canSeeOthers + ? members + .map((member) => ({ + membershipId: member.id, + name: nameById.get(member.id) ?? member.employeeNumber, + })) + .sort((a, b) => a.name.localeCompare(b.name, 'fr')) + : [ + { + membershipId: actor.membershipId, + name: nameById.get(actor.membershipId) ?? 'Moi', + }, + ], + canDecide, + canAdjust, + ownMembershipId: actor.membershipId, + }; + }); +} + +/** Absences acceptées d'une période, pour la grille de planning. */ +export async function acceptedAbsencesBetween( + membershipIds: string[], + from: string, + to: string, +): Promise< + Array<{ + membershipId: string; + startDate: string; + endDate: string; + label: string; + colorKey: string; + }> +> { + return query('planning.view', async (db) => { + const rows = await db.timeOff.findMany({ + where: { + membershipId: { in: membershipIds }, + status: 'ACCEPTED', + startDate: { lte: new Date(`${to}T00:00:00Z`) }, + endDate: { gte: new Date(`${from}T00:00:00Z`) }, + }, + include: { absenceType: true }, + }); + + return rows.map((row) => ({ + membershipId: row.membershipId, + startDate: row.startDate.toISOString().slice(0, 10), + endDate: row.endDate.toISOString().slice(0, 10), + // La grille de planning n'a jamais besoin du motif médical : « Absence » + // suffit à ne pas planifier quelqu'un. + label: row.absenceType.isSocialSecurity + ? 'Absence' + : row.absenceType.name, + colorKey: row.absenceType.colorKey, + })); + }); +} diff --git a/src/server/planning/queries.ts b/src/server/planning/queries.ts index 9f77a04..fd51489 100644 --- a/src/server/planning/queries.ts +++ b/src/server/planning/queries.ts @@ -178,6 +178,32 @@ export async function getWeekBoard( const people = await loadPeople(db, teams.map((team) => team.id)); + // Les absences acceptées s'affichent sur la grille : planifier quelqu'un + // qui est en congé est l'erreur que cette bande empêche. + const allMemberIds = [...people.values()] + .flat() + .map((person) => person.membershipId); + const absences = await db.timeOff.findMany({ + where: { + membershipId: { in: allMemberIds }, + status: 'ACCEPTED', + startDate: { lte: new Date(`${dates[6]}T00:00:00Z`) }, + endDate: { gte: new Date(`${dates[0]}T00:00:00Z`) }, + }, + include: { absenceType: true }, + }); + const absenceInputs = absences.map((absence) => ({ + membershipId: absence.membershipId, + startDate: absence.startDate.toISOString().slice(0, 10), + endDate: absence.endDate.toISOString().slice(0, 10), + // La grille n'a jamais besoin du motif médical : « Absence » suffit à + // ne pas planifier quelqu'un, et c'est une donnée de santé. + label: absence.absenceType.isSocialSecurity + ? 'Absence' + : absence.absenceType.name, + colorKey: absence.absenceType.colorKey, + })); + // Les constats sont lus, jamais recalculés à l'affichage : ils datent de // la dernière écriture, avec la version de convention qui s'appliquait // alors. Les recalculer ici les ferait diverger de ce qui a été acquitté. @@ -209,6 +235,7 @@ export async function getWeekBoard( dates, location.timezone, isPublished, + absenceInputs, ); const teamAlerts = alerts diff --git a/tests/e2e/conges.spec.ts b/tests/e2e/conges.spec.ts new file mode 100644 index 0000000..dfd0d4c --- /dev/null +++ b/tests/e2e/conges.spec.ts @@ -0,0 +1,208 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { frenchHolidays } from '../../src/domain/absences/holidays'; + +/** + * Parcours des absences. + * + * Ce que ces tests protègent en priorité : que « dernier jour d'absence » soit + * bien traité comme tel. La confusion avec la date de reprise décompte un jour + * de trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au + * solde, des mois plus tard. + */ + +/** + * Décalage propre à cette exécution. + * + * La base de test n'est pas remise à zéro entre deux passages, et une absence + * acceptée bloque toute demande qui la recouvre : des dates fixes feraient + * échouer le deuxième passage pour une raison sans rapport avec ce qui est + * testé. Chaque exécution travaille donc sur sa propre fenêtre de dates. + */ +const RUN_OFFSET = 150 + (Math.floor(Date.now() / 1000) % 700); + +/** Une date libre, loin de tout ce que le seed pose. */ +function isoDate(offsetDays: number): string { + return new Date(Date.now() + (RUN_OFFSET + offsetDays) * 86_400_000) + .toISOString() + .slice(0, 10); +} + +/** Prochain jour de la semaine demandé (0 = dimanche) dans la fenêtre du run. */ +function nextWeekday(weekday: number): string { + const cursor = new Date(Date.now() + (RUN_OFFSET + 40) * 86_400_000); + while (cursor.getUTCDay() !== weekday) { + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return cursor.toISOString().slice(0, 10); +} + +async function request( + page: Page, + options: { from: string; to: string; type?: string; comment?: string }, +) { + const form = page.locator('form').filter({ hasText: 'Demander' }).first(); + if (options.type) { + await form.getByLabel('Type').selectOption({ label: options.type }); + } + await form.getByLabel('Premier jour').fill(options.from); + await form.getByLabel('Dernier jour d’absence').fill(options.to); + if (options.comment) { + await form.getByLabel('Commentaire').fill(options.comment); + } + await form.getByRole('button', { name: 'Demander' }).click(); + return form; +} + +test('le formulaire nomme le dernier jour d’absence, pas la date de fin', async ({ + page, +}) => { + await page.goto('/conges'); + // Le libellé exact coûte moins cher qu'une régularisation de solde. + await expect( + page.getByText('Dernier jour d’absence', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText(/veille de la reprise/), + ).toBeVisible(); +}); + +test('une demande apparaît dans la file, puis se décide', async ({ page }) => { + await page.goto('/conges'); + + const from = isoDate(0); + const to = isoDate(4); + const comment = `Test ${Date.now()}`; + await request(page, { from, to, type: 'Congés payés', comment }); + + const pending = page + .locator('section') + .filter({ hasText: 'Demandes en attente' }); + const row = pending.locator('li').filter({ hasText: comment }); + await expect(row).toBeVisible(); + + await row.getByRole('button', { name: 'Accepter' }).click(); + + // Acceptée, elle quitte la file et rejoint les absences du mois. + await expect(pending.locator('li').filter({ hasText: comment })).toHaveCount( + 0, + ); +}); + +test('deux absences qui se recouvrent sont refusées', async ({ page }) => { + await page.goto('/conges'); + + const comment = `Chevauchement ${Date.now()}`; + await request(page, { + from: isoDate(60), + to: isoDate(65), + type: 'Congés payés', + comment, + }); + + // Attendre que la première soit réellement enregistrée : soumettre la + // seconde pendant la revalidation ferait porter le clic dans le vide, et le + // test passerait pour de mauvaises raisons. + const pending = page + .locator('section') + .filter({ hasText: 'Demandes en attente' }); + await expect(pending.locator('li').filter({ hasText: comment })).toBeVisible(); + + // La seconde chevauche la première d'un seul jour : bornes inclusives des + // deux côtés, puisque la date de fin est un jour d'absence. + const form = await request(page, { + from: isoDate(65), + to: isoDate(68), + type: 'Congés payés', + }); + await expect(form.getByText(/couvre déjà/)).toBeVisible(); +}); + +test('une période sans jour décomptable est refusée', async ({ page }) => { + await page.goto('/conges'); + + // Un dimanche seul : jamais ouvrable, donc rien à décompter. + const sunday = nextWeekday(0); + const form = await request(page, { + from: sunday, + to: sunday, + type: 'Congés payés', + }); + await expect(form.getByText(/aucun jour décomptable/)).toBeVisible(); +}); + +test('un jour férié dans la période n’est pas décompté', async ({ page }) => { + await page.goto('/conges'); + + // Un jour férié pris au hasard parmi ceux de l'année : la période qui + // l'englobe ne doit pas le décompter, et l'utilisateur n'a pas eu à scinder + // sa demande. Le tirage évite de retomber sur la même semaine à chaque run. + const holidays = frenchHolidays(new Date().getUTCFullYear() + 2); + const holiday = holidays[ + Math.floor(Date.now() / 1000) % holidays.length + ] as (typeof holidays)[number]; + + const day = (offset: number) => + new Date( + new Date(`${holiday.isoDate}T00:00:00Z`).getTime() + offset * 86_400_000, + ) + .toISOString() + .slice(0, 10); + + const comment = `Ferie ${Date.now()}`; + await request(page, { + from: day(-1), + to: day(1), + type: 'Congés payés', + comment, + }); + + const row = page.locator('li').filter({ hasText: comment }); + await expect(row).toBeVisible(); + // Trois jours calendaires dont un férié : jamais trois décomptés. + await expect(row.getByText(/3 jours décomptés/)).toHaveCount(0); +}); + +test('une date de fin antérieure au début rappelle la règle', async ({ + page, +}) => { + await page.goto('/conges'); + + const form = await request(page, { + from: isoDate(100), + to: isoDate(98), + type: 'Congés payés', + }); + await expect(form.getByRole('alert')).toContainText( + /dernier jour d’absence/i, + ); +}); + +test('le solde revient à son niveau après un aller-retour', async ({ page }) => { + await page.goto('/conges'); + + const counters = page.locator('section').filter({ hasText: 'Compteurs' }); + await expect(counters).toBeVisible(); + + const from = isoDate(120); + const to = isoDate(122); + const comment = `Aller-retour ${Date.now()}`; + await request(page, { from, to, type: 'Congés payés', comment }); + + const pending = page + .locator('section') + .filter({ hasText: 'Demandes en attente' }); + const row = pending.locator('li').filter({ hasText: comment }); + await row.getByRole('button', { name: 'Accepter' }).click(); + await expect(pending.locator('li').filter({ hasText: comment })).toHaveCount( + 0, + ); + + // Annuler contre-passe la prise : le solde revient au même chiffre, sans + // qu'aucune écriture ait été effacée. + const month = page.locator('section').filter({ hasText: 'Absences du mois' }); + const accepted = month.locator('li').filter({ hasText: 'Acceptée' }).first(); + if (await accepted.isVisible()) { + await accepted.getByRole('button', { name: 'Annuler' }).click(); + } +}); diff --git a/tests/integration/ledger.test.ts b/tests/integration/ledger.test.ts new file mode 100644 index 0000000..6de577a --- /dev/null +++ b/tests/integration/ledger.test.ts @@ -0,0 +1,147 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * Immutabilité du registre — critère d'acceptation de WP-06. + * + * La règle est **en base**, pas seulement dans l'application : une règle + * applicative finit par être contournée par un script de reprise, une console + * d'administration ou une migration pressée. Ces tests écrivent directement, + * sans passer par les Server Actions, pour le prouver. + */ + +const enabled = (process.env.DATABASE_URL ?? '').length > 0; +const describeIfDb = enabled ? describe : describe.skip; + +const suffix = `ledger-${Date.now()}`; +const accountId = `${suffix}-account`; +const membershipId = `${suffix}-member`; + +let unscoped: typeof import('@/server/tenant').unscoped; +let counterId = ''; +let operationId = ''; + +describeIfDb('registre des compteurs', () => { + beforeAll(async () => { + process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64'); + ({ unscoped } = await import('@/server/tenant')); + const db = unscoped(); + + await db.account.create({ + data: { id: accountId, name: `Compte ${suffix}` }, + }); + const role = await db.role.create({ + data: { accountId, key: 'employee', name: 'Employé' }, + }); + await db.membership.create({ + data: { + id: membershipId, + accountId, + roleId: role.id, + employeeNumber: 'L0001', + status: 'ACTIVE', + }, + }); + + const counter = await db.counter.create({ + data: { + accountId, + membershipId, + counterType: 'PAID_LEAVE', + acquisitionPeriodStart: new Date('2026-06-01'), + acquisitionPeriodEnd: new Date('2027-05-31'), + }, + }); + counterId = counter.id; + + const operation = await db.ledgerOperation.create({ + data: { + accountId, + counterId, + kind: 'ACCRUAL', + quantity: 25, + unit: 'DAY', + effectiveDate: new Date('2026-06-01'), + sourceType: 'SYSTEM', + }, + }); + operationId = operation.id; + }); + + afterAll(async () => { + if (!enabled) return; + const db = unscoped(); + // Le trigger interdit DELETE sur les écritures : la suppression du compte + // ne peut donc pas cascader. On le désactive le temps du ménage. + await db.$executeRawUnsafe( + 'ALTER TABLE "LedgerOperation" DISABLE TRIGGER ledger_operation_append_only', + ); + await db.account.delete({ where: { id: accountId } }); + await db.$executeRawUnsafe( + 'ALTER TABLE "LedgerOperation" ENABLE TRIGGER ledger_operation_append_only', + ); + }); + + it('refuse toute modification d’écriture', async () => { + await expect( + unscoped().ledgerOperation.update({ + where: { id: operationId }, + data: { quantity: 999 }, + }), + ).rejects.toThrow(/append-only/); + }); + + it('refuse toute suppression d’écriture', async () => { + await expect( + unscoped().ledgerOperation.delete({ where: { id: operationId } }), + ).rejects.toThrow(/append-only/); + }); + + it('accepte une contre-passation, qui laisse les deux écritures', async () => { + // Une correction s'écrit ; elle ne se réécrit pas. Les deux lignes + // coexistent, et le solde redevient juste par addition. + const db = unscoped(); + await db.ledgerOperation.create({ + data: { + accountId, + counterId, + kind: 'REGULARISATION', + quantity: -25, + unit: 'DAY', + effectiveDate: new Date('2026-07-01'), + sourceType: 'MANUAL', + reason: 'Correction du solde d’ouverture', + reversesId: operationId, + }, + }); + + const operations = await db.ledgerOperation.findMany({ + where: { counterId }, + }); + expect(operations).toHaveLength(2); + + const balance = operations.reduce( + (sum, operation) => sum + Number(operation.quantity.toString()), + 0, + ); + expect(balance).toBe(0); + }); + + it('n’accepte qu’une seule contre-passation par écriture', async () => { + // `reversesId` est unique : contre-passer deux fois la même écriture + // doublerait la correction, et le solde partirait dans l'autre sens. + await expect( + unscoped().ledgerOperation.create({ + data: { + accountId, + counterId, + kind: 'REGULARISATION', + quantity: -25, + unit: 'DAY', + effectiveDate: new Date('2026-07-02'), + sourceType: 'MANUAL', + reversesId: operationId, + }, + }), + ).rejects.toThrow(); + }); +}); diff --git a/tests/unit/absences.test.ts b/tests/unit/absences.test.ts new file mode 100644 index 0000000..645ed45 --- /dev/null +++ b/tests/unit/absences.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it } from 'vitest'; + +import { + absenceDayCount, + countAbsenceDays, + datesBetween, + findAbsenceOverlaps, + isRetroactive, + isoWeekday, + respectsNotice, +} from '@/domain/absences/count'; +import { + accrualForMonth, + balanceAt, + balanceOf, + counterView, + reversalOf, + type LedgerEntry, +} from '@/domain/absences/ledger'; + +/** Août 2026 : le 15 est un samedi férié, le 14 un vendredi. */ +const AOUT_2026 = ['2026-08-15']; + +describe('endDate est le dernier jour d’absence', () => { + it('décompte les deux bornes', () => { + // Du lundi 10 au vendredi 14 : cinq jours, pas quatre. La confusion avec + // la date de reprise décompte un jour de trop ou de trop peu à chaque + // demande, et le salarié s'en aperçoit au solde, des mois plus tard. + expect( + absenceDayCount( + { startDate: '2026-08-10', endDate: '2026-08-14' }, + { holidays: [] }, + ), + ).toBe(5); + }); + + it('compte un jour pour une absence d’une journée', () => { + expect( + absenceDayCount( + { startDate: '2026-08-10', endDate: '2026-08-10' }, + { holidays: [] }, + ), + ).toBe(1); + }); + + it('ne décompte pas le jour de reprise', () => { + // Reprise le lundi 17 : l'absence s'arrête au vendredi 14. + const withReturnDate = absenceDayCount( + { startDate: '2026-08-10', endDate: '2026-08-17' }, + { holidays: [] }, + ); + const correct = absenceDayCount( + { startDate: '2026-08-10', endDate: '2026-08-14' }, + { holidays: [] }, + ); + expect(correct).toBe(5); + expect(withReturnDate).toBe(6); + }); + + it('rend zéro sur une période inversée', () => { + expect( + absenceDayCount( + { startDate: '2026-08-14', endDate: '2026-08-10' }, + { holidays: [] }, + ), + ).toBe(0); + }); +}); + +describe('jours fériés', () => { + it('retire le férié sans exiger de scinder la demande', () => { + // Du lundi 10 au lundi 17, le samedi 15 est férié. En ouvrables : + // 10, 11, 12, 13, 14, 17 comptent ; le 15 est férié, le 16 dimanche. + const { total, days } = countAbsenceDays( + { startDate: '2026-08-10', endDate: '2026-08-17' }, + { holidays: AOUT_2026 }, + ); + expect(total).toBe(6); + expect(days.find((day) => day.isoDate === '2026-08-15')?.reason).toBe( + 'HOLIDAY', + ); + }); + + it('ne décompte rien sur un congé entièrement férié', () => { + expect( + absenceDayCount( + { startDate: '2026-08-15', endDate: '2026-08-15' }, + { holidays: AOUT_2026 }, + ), + ).toBe(0); + }); +}); + +describe('base de décompte', () => { + it('compte le samedi en ouvrables', () => { + // Du lundi 10 au samedi 15 sans férié : six jours ouvrables. + expect( + absenceDayCount( + { startDate: '2026-08-10', endDate: '2026-08-15' }, + { holidays: [], basis: 'OUVRABLES', workingWeekdays: [1, 2, 3, 4, 5, 6] }, + ), + ).toBe(6); + }); + + it('exclut le samedi en ouvrés', () => { + expect( + absenceDayCount( + { startDate: '2026-08-10', endDate: '2026-08-15' }, + { holidays: [], basis: 'OUVRES', workingWeekdays: [1, 2, 3, 4, 5, 6] }, + ), + ).toBe(5); + }); + + it('respecte le rythme du contrat', () => { + // Un temps partiel qui ne travaille jamais le mercredi ne consomme pas de + // congé ce jour-là. + const { total, days } = countAbsenceDays( + { startDate: '2026-08-10', endDate: '2026-08-14' }, + { holidays: [], workingWeekdays: [1, 2, 4, 5] }, + ); + expect(total).toBe(4); + expect(days.find((day) => day.isoDate === '2026-08-12')?.reason).toBe( + 'NOT_WORKED', + ); + }); +}); + +describe('demi-journées', () => { + it('retire une demi-journée à chaque borne marquée', () => { + expect( + absenceDayCount( + { + startDate: '2026-08-10', + endDate: '2026-08-14', + startHalfDay: true, + endHalfDay: true, + }, + { holidays: [] }, + ), + ).toBe(4); + }); + + it('compte une demi-journée pour une absence d’une demi-journée', () => { + expect( + absenceDayCount( + { + startDate: '2026-08-10', + endDate: '2026-08-10', + startHalfDay: true, + }, + { holidays: [] }, + ), + ).toBe(0.5); + }); + + it('ne retire rien de plus sur une borne fériée', () => { + // Une demi-journée posée sur un férié ne peut pas rendre le décompte + // négatif : le férié ne se décompte déjà pas. + expect( + absenceDayCount( + { + startDate: '2026-08-15', + endDate: '2026-08-17', + startHalfDay: true, + }, + { holidays: AOUT_2026 }, + ), + ).toBe(1); + }); +}); + +describe('chevauchements', () => { + const existing = [ + { id: 'a', startDate: '2026-08-10', endDate: '2026-08-14' }, + ]; + + it('détecte un recouvrement partiel', () => { + expect( + findAbsenceOverlaps( + { startDate: '2026-08-13', endDate: '2026-08-20' }, + existing, + ), + ).toHaveLength(1); + }); + + it('détecte deux congés qui se touchent le même jour', () => { + // Bornes inclusives des deux côtés : `endDate` est un jour d'absence, donc + // une comparaison exclusive laisserait passer ce cas. + expect( + findAbsenceOverlaps( + { startDate: '2026-08-14', endDate: '2026-08-18' }, + existing, + ), + ).toHaveLength(1); + }); + + it('accepte deux congés consécutifs sans recouvrement', () => { + expect( + findAbsenceOverlaps( + { startDate: '2026-08-15', endDate: '2026-08-18' }, + existing, + ), + ).toHaveLength(0); + }); + + it('ignore l’absence en cours de modification', () => { + expect( + findAbsenceOverlaps( + { startDate: '2026-08-10', endDate: '2026-08-14' }, + existing, + 'a', + ), + ).toHaveLength(0); + }); +}); + +describe('préavis et rétroactivité', () => { + it('accepte une demande respectant le préavis', () => { + expect( + respectsNotice({ startDate: '2026-08-20', endDate: '2026-08-21' }, '2026-08-10', 7), + ).toBe(true); + expect( + respectsNotice({ startDate: '2026-08-16', endDate: '2026-08-17' }, '2026-08-10', 7), + ).toBe(false); + }); + + it('n’exige rien quand aucun préavis n’est paramétré', () => { + expect( + respectsNotice({ startDate: '2026-08-11', endDate: '2026-08-11' }, '2026-08-10', null), + ).toBe(true); + }); + + it('signale une demande rétroactive sans l’interdire', () => { + // Un arrêt maladie se déclare toujours après coup : c'est l'écran qui + // avertit, pas le domaine qui bloque. + expect( + isRetroactive({ startDate: '2026-08-01', endDate: '2026-08-05' }, '2026-08-10'), + ).toBe(true); + }); +}); + +describe('utilitaires de dates', () => { + it('numérote lundi 1 et dimanche 7', () => { + expect(isoWeekday('2026-08-10')).toBe(1); + expect(isoWeekday('2026-08-16')).toBe(7); + }); + + it('énumère les bornes incluses', () => { + expect(datesBetween('2026-08-10', '2026-08-12')).toEqual([ + '2026-08-10', + '2026-08-11', + '2026-08-12', + ]); + }); +}); + +describe('registre des compteurs', () => { + const entries: LedgerEntry[] = [ + { kind: 'ACCRUAL', quantity: 2.5, unit: 'DAY', effectiveDate: '2026-01-31' }, + { kind: 'ACCRUAL', quantity: 2.5, unit: 'DAY', effectiveDate: '2026-02-28' }, + { kind: 'TAKEN', quantity: -3, unit: 'DAY', effectiveDate: '2026-02-10' }, + ]; + + it('somme les écritures plutôt que de stocker un solde', () => { + // Un solde stocké se désynchronise, et la désynchronisation ne se voit + // qu'au moment où un salarié conteste. + expect(balanceOf(entries)).toBe(2); + }); + + it('donne le solde à une date passée', () => { + expect(balanceAt(entries, '2026-01-31')).toBe(2.5); + expect(balanceAt(entries, '2026-02-10')).toBe(-0.5); + }); + + it('projette les acquisitions restantes', () => { + const view = counterView(entries, 25); + expect(view.accrued).toBe(5); + expect(view.taken).toBe(3); + expect(view.balance).toBe(2); + expect(view.projected).toBe(27); + }); + + it('contre-passe à la date de la correction, pas à celle de l’écriture', () => { + // Antidater masquerait la correction dans les soldes déjà communiqués. + const original = entries[2] as LedgerEntry; + const reversal = reversalOf(original, '2026-03-15'); + expect(reversal.quantity).toBe(3); + expect(reversal.effectiveDate).toBe('2026-03-15'); + expect(balanceOf([...entries, reversal])).toBe(5); + }); +}); + +describe('acquisition de congés payés', () => { + it('acquiert 2,5 jours par mois travaillé', () => { + expect(accrualForMonth(true, 0)).toBe(2.5); + }); + + it('continue d’acquérir pendant un arrêt maladie', () => { + // Droit issu de la réforme de 2024 : l'oublier prive le salarié d'un droit + // acquis, sans que rien ne le signale. + expect(accrualForMonth(false, 0)).toBe(2); + expect(accrualForMonth(false, 5)).toBe(2); + }); + + it('plafonne l’acquisition maladie à 24 jours par an', () => { + // Douze mois d'arrêt à 2 jours feraient 24 : le treizième n'acquiert rien. + expect(accrualForMonth(false, 11)).toBe(2); + expect(accrualForMonth(false, 12)).toBe(0); + }); + + it('acquiert le reliquat exact au dernier mois sous plafond', () => { + expect(accrualForMonth(false, 11.5)).toBe(1); + }); +}); + +describe('jours fériés légaux', () => { + it('calcule Pâques sur des années de référence', async () => { + const { easterSunday } = await import('@/domain/absences/holidays'); + // Une table écrite à la main est juste l'année où on l'écrit et fausse dès + // la suivante : trois jours fériés dépendent de cette date. + expect(easterSunday(2026).toISOString().slice(0, 10)).toBe('2026-04-05'); + expect(easterSunday(2027).toISOString().slice(0, 10)).toBe('2027-03-28'); + expect(easterSunday(2024).toISOString().slice(0, 10)).toBe('2024-03-31'); + expect(easterSunday(2038).toISOString().slice(0, 10)).toBe('2038-04-25'); + }); + + it('donne onze jours fériés par an', async () => { + const { frenchHolidays } = await import('@/domain/absences/holidays'); + const holidays = frenchHolidays(2026); + expect(holidays).toHaveLength(11); + expect(holidays.map((entry) => entry.isoDate)).toContain('2026-04-06'); + expect(holidays.map((entry) => entry.isoDate)).toContain('2026-05-14'); + expect(holidays.map((entry) => entry.isoDate)).toContain('2026-05-25'); + }); + + it('reste juste l’année suivante', async () => { + const { frenchHolidays } = await import('@/domain/absences/holidays'); + const dates = frenchHolidays(2027).map((entry) => entry.isoDate); + expect(dates).toContain('2027-03-29'); + expect(dates).toContain('2027-05-06'); + expect(dates).toContain('2027-05-17'); + }); + + it('n’identifie que le 1er mai comme chômé de droit', async () => { + const { isLabourDay } = await import('@/domain/absences/holidays'); + expect(isLabourDay('2027-05-01')).toBe(true); + expect(isLabourDay('2027-07-14')).toBe(false); + }); +});