Merge pull request #14 — WP-06 absences et compteurs

WP-06 — Absences, registre de compteurs, calendrier
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-08 10:31:32 +02:00
commit 54a9f3ee2c
18 files changed
+3160 -228

No files matched your search

@@ -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();
+178
View File
@@ -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])
}
+165 -44
View File
@@ -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
+304 -123
View File
@@ -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<string, string> = {
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<string, string> = {
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<string, Tone> = {
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 (
<PageBody>
<PageHeader
title="Calendrier des absences"
subtitle={`${MONTH_LABEL} · Nantes Atlantis · ${PENDING_REQUESTS.length} demandes en attente`}
subtitle={`${board.label} · ${board.inMonth.length} absence${board.inMonth.length > 1 ? 's' : ''} sur le mois`}
actions={
<>
<Button>Exporter</Button>
<Button variant="primary">Saisir une absence</Button>
<Link
href={href(board.previousParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Mois précédent
</Link>
<Link
href={href(board.nextParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Mois suivant →
</Link>
</>
}
/>
<Card>
<div className="grid grid-cols-7 border-b border-line-2 bg-surface-2">
{WEEKDAYS.map((day) => (
<div
key={day}
className="border-r border-line-1 px-2 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase last:border-r-0"
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{cells.map((cell) => (
<div
key={cell.index}
className={cx(
'min-h-24 border-r border-b border-line-1 p-1.5 last:border-r-0',
!cell.inMonth && 'bg-surface-2',
cell.inMonth && cell.weekend && 'bg-surface-2',
)}
>
<span
className={cx(
'tnum block text-micro font-semibold',
cell.inMonth ? 'text-ink-1' : 'text-ink-3',
)}
>
{cell.inMonth ? cell.dayNumber : ''}
</span>
<ul className="mt-1 flex flex-col gap-0.5">
{cell.items.slice(0, 3).map((absence) => (
<li
key={`${absence.who}-${absence.type}`}
className={cx(
'truncate rounded-1 border px-1 py-px text-micro',
KIND_STYLES[absence.kind],
)}
title={`${absence.who} · ${absence.type}`}
>
{absence.who.split(' ')[0]} · {absence.type}
</li>
))}
{cell.items.length > 3 ? (
<li className="px-1 text-micro text-ink-3">
+{cell.items.length - 3}
</li>
) : null}
</ul>
</div>
))}
</div>
</Card>
<RequestForm
absenceTypes={board.absenceTypes}
people={board.people}
ownMembershipId={board.ownMembershipId}
/>
<Card>
<span id="attente" />
<CardHeader
title="Demandes en attente"
badge={<Badge tone="warn">{PENDING_REQUESTS.length}</Badge>}
/>
<ul>
{PENDING_REQUESTS.map((request) => (
<li
key={`${request.who}-${request.range}`}
className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{request.who} · {request.type}
<section id="attente" className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Demandes en attente
{board.pending.length > 0 ? ` · ${board.pending.length}` : ''}
</h2>
{board.pending.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucune demande à traiter.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.pending.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-3 px-4 py-3"
>
<div className="min-w-56 flex-1">
<p className="text-sm font-medium text-ink-1">
{request.name} · {request.typeLabel}
</p>
<p className="text-micro text-ink-3">
du {frenchDate(request.startDate)} au{' '}
{frenchDate(request.endDate)} ·{' '}
<span className="tnum">{request.days}</span> jour
{request.days > 1 ? 's' : ''} décompté
{request.days > 1 ? 's' : ''}
</p>
{request.comment ? (
<p className="mt-0.5 text-micro text-ink-2">
« {request.comment} »
</p>
) : null}
</div>
{board.canDecide ? (
<DecisionButtons timeOffId={request.id} />
) : (
<CancelButton timeOffId={request.id} />
)}
</li>
))}
</ul>
)}
</section>
<MonthCalendar board={board} />
<section className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Absences du mois
</h2>
{board.inMonth.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucune absence sur cette période.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.inMonth.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-3 px-4 py-2.5"
>
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
{statusLabel(request.status)}
</Badge>
<span className="text-sm text-ink-1">{request.name}</span>
<span className="text-sm text-ink-2">{request.typeLabel}</span>
<span className="text-micro text-ink-3">
{frenchDate(request.startDate)} → {frenchDate(request.endDate)}
</span>
<span className="block text-micro text-ink-3">
{request.note}
<span className="tnum ml-auto text-xs text-ink-2">
{request.days} j
</span>
</span>
<span className="tnum flex-none text-xs text-ink-2">
{request.range}
</span>
<Badge tone="neutral">{request.days}</Badge>
<span className="flex flex-none gap-2">
<Button size="sm">Refuser</Button>
<Button size="sm" variant="primary">
Accepter
</Button>
</span>
</li>
))}
</ul>
</Card>
{request.status === 'ACCEPTED' ? (
<CancelButton timeOffId={request.id} />
) : null}
</li>
))}
</ul>
)}
</section>
<section className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Compteurs
</h2>
{board.counters.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucun compteur ouvert. Un compteur se crée à la première écriture —
acceptation d’un congé ou ajustement manuel.
</p>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
Salarié
</th>
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
Compteur
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Acquis
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Pris
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Solde
</th>
</tr>
</thead>
<tbody>
{board.counters.map((counter) => (
<tr
key={`${counter.membershipId}-${counter.counterType}`}
className="border-b border-line-1 last:border-b-0"
>
<td className="px-4 py-1.5 text-ink-1">{counter.name}</td>
<td className="px-4 py-1.5 text-ink-2">
{COUNTER_LABELS[counter.counterType] ?? counter.counterType}
</td>
<td className="tnum px-4 py-1.5 text-right text-ink-2">
{counter.accrued}
</td>
<td className="tnum px-4 py-1.5 text-right text-ink-2">
{counter.taken}
</td>
<td className="tnum px-4 py-1.5 text-right font-semibold text-ink-1">
{counter.balance}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="border-t border-line-1 px-4 py-2 text-micro text-ink-3">
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.
</p>
</section>
</PageBody>
);
}
/** Calendrier du mois, une colonne par jour. */
function MonthCalendar({
board,
}: {
board: Awaited<ReturnType<typeof getAbsenceBoard>>;
}) {
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 (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<table className="w-full border-collapse text-xs">
<caption className="sr-only">
Calendrier des absences, {board.label}
</caption>
<thead>
<tr className="border-b border-line-2 bg-surface-2">
<th
scope="col"
className="sticky left-0 z-10 min-w-40 bg-surface-2 px-3 py-2 text-left text-micro font-semibold text-ink-3 uppercase"
>
Salarié
</th>
{board.days.map((date) => (
<th
key={date}
scope="col"
className={cx(
'tnum w-7 px-0.5 py-2 text-center font-semibold',
isoWeekday(date) >= 6
? 'bg-surface-3 text-ink-2'
: 'text-ink-1',
)}
>
{Number(date.slice(8, 10))}
</th>
))}
</tr>
</thead>
<tbody>
{people.map((membershipId) => {
const own = accepted.filter(
(request) => request.membershipId === membershipId,
);
const covered = new Map<string, (typeof own)[number]>();
for (const request of own) {
for (const date of datesBetween(
request.startDate,
request.endDate,
)) {
covered.set(date, request);
}
}
return (
<tr
key={membershipId}
className="border-b border-line-1 last:border-b-0"
>
<th
scope="row"
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
>
{own[0]?.name ?? ''}
</th>
{board.days.map((date) => {
const request = covered.get(date);
return (
<td
key={date}
className={cx(
'border-l border-line-1 px-0.5 py-1.5 text-center',
isoWeekday(date) >= 6 && 'bg-surface-2',
)}
title={
request
? `${request.typeLabel} — ${statusLabel(request.status)}`
: undefined
}
>
{request ? (
<span
className={cx(
'block h-4 rounded-1',
request.status === 'PENDING'
? 'border border-dashed border-warn bg-warn-soft'
: 'bg-info-soft',
)}
>
<span className="sr-only">
{request.typeLabel} le {date}
</span>
</span>
) : null}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function frenchDate(isoDate: string): string {
return isoDate.split('-').reverse().join('/');
}
@@ -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 (
<form action={formAction} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="timeOffId" value={timeOffId} />
<input
name="comment"
type="text"
maxLength={500}
placeholder="Commentaire"
className="h-7 w-40 rounded-2 border border-line-2 bg-surface px-2 text-xs text-ink-1"
/>
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
<Button
type="submit"
name="accept"
value="true"
size="sm"
variant="primary"
disabled={pending}
>
Accepter
</Button>
<Button
type="submit"
name="accept"
value="false"
size="sm"
disabled={pending}
>
Refuser
</Button>
</form>
);
}
/**
* 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 (
<form action={formAction} className="flex items-center gap-2">
<input type="hidden" name="timeOffId" value={timeOffId} />
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
<Button type="submit" size="sm" disabled={pending}>
Annuler
</Button>
</form>
);
}
+153
View File
@@ -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 (
<form
action={formAction}
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
>
<Field label="Salarié" htmlFor="req-member">
<select
id="req-member"
name="membershipId"
defaultValue={ownMembershipId}
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{people.map((person) => (
<option key={person.membershipId} value={person.membershipId}>
{person.name}
</option>
))}
</select>
</Field>
<Field label="Type" htmlFor="req-type">
<select
id="req-type"
name="absenceTypeId"
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{absenceTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
</Field>
<Field label="Premier jour" htmlFor="req-start">
<input
id="req-start"
name="startDate"
type="date"
required
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
<Field label="Dernier jour d’absence" htmlFor="req-end">
<input
id="req-end"
name="endDate"
type="date"
required
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
<label className="flex items-center gap-1.5 text-xs text-ink-2">
<input name="startHalfDay" type="checkbox" className="size-4" />
Demi-journée au début
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-2">
<input name="endHalfDay" type="checkbox" className="size-4" />
Demi-journée à la fin
</label>
<Field label="Commentaire" htmlFor="req-comment">
<input
id="req-comment"
name="comment"
type="text"
maxLength={500}
className="h-8 w-56 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
<Button type="submit" variant="primary" size="sm" disabled={pending}>
Demander
</Button>
<p className="w-full text-micro text-ink-3">
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.
</p>
{state.error ? (
<p role="alert" className="w-full text-xs text-danger">
{state.error}
</p>
) : null}
{state.warning ? (
<p className="w-full text-xs text-warn-soft-ink">{state.warning}</p>
) : null}
{state.ok && !state.warning ? (
<p className="w-full text-xs text-ok-soft-ink">Demande enregistrée.</p>
) : null}
</form>
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label
htmlFor={htmlFor}
className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase"
>
{label}
</label>
{children}
</div>
);
}
+33
View File
@@ -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({
</div>
<div className="relative grid flex-1 grid-cols-7">
{row.absences.map((absence, position) => (
<AbsenceBar
key={`${absence.startDay}-${position}`}
kind={absenceKind(absence.colorKey)}
label={absence.label}
duration={`${absence.span} j`}
startDay={absence.startDay}
span={absence.span}
className="top-1.5"
/>
))}
{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({
</div>
);
}
/**
* 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';
}
+179
View File
@@ -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<AbsenceRange, 'startDate' | 'endDate'>,
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;
}
+83
View File
@@ -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';
}
+145
View File
@@ -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;
}
+74 -3
View File
@@ -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);
}
-58
View File
@@ -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',
},
];
+586
View File
@@ -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<AbsenceActionState> {
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<AbsenceActionState> {
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<AbsenceActionState> {
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<AbsenceActionState> {
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<void> {
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<void> {
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;
}
+263
View File
@@ -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<AbsenceBoard> {
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,
}));
});
}
+27
View File
@@ -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
+208
View File
@@ -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();
}
});
+147
View File
@@ -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();
});
});
+349
View File
@@ -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);
});
});