Merge pull request #14 — WP-06 absences et compteurs
WP-06 — Absences, registre de compteurs, calendrier
This commit is contained in:
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();
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user