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?
|
archivedAt DateTime?
|
||||||
|
|
||||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||||
|
timeOffs TimeOff[]
|
||||||
|
|
||||||
@@unique([accountId, code])
|
@@unique([accountId, code])
|
||||||
@@index([accountId])
|
@@index([accountId])
|
||||||
@@ -834,3 +835,180 @@ model PayrollExport {
|
|||||||
@@index([accountId, periodStart])
|
@@index([accountId, periodStart])
|
||||||
@@index([accountId])
|
@@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_PARAMETERS,
|
||||||
IDCC_1517_PROVENANCE,
|
IDCC_1517_PROVENANCE,
|
||||||
} from '../src/domain/compliance/idcc1517';
|
} from '../src/domain/compliance/idcc1517';
|
||||||
|
import { frenchHolidays } from '../src/domain/absences/holidays';
|
||||||
import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements';
|
import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements';
|
||||||
import { evaluateSchedule } from '../src/server/compliance/evaluate';
|
import { evaluateSchedule } from '../src/server/compliance/evaluate';
|
||||||
import { withTenant } from '../src/server/tenant';
|
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(` ${IDCC_1517_PROVENANCE.length} paramètres tracés`);
|
||||||
|
|
||||||
console.log('→ Jours fériés et dimanches autorisés');
|
console.log('→ Jours fériés et dimanches autorisés');
|
||||||
// Jours fériés légaux français, hors Alsace-Moselle.
|
// Calculés, pas listés : une table écrite à la main est juste l'année où on
|
||||||
const holidays2026: Array<[string, string]> = [
|
// l'écrit. Un férié manquant se décompte comme un jour de congé, et le
|
||||||
['2026-01-01', 'Jour de l’an'],
|
// salarié perd un jour sans que personne ne le voie.
|
||||||
['2026-04-06', 'Lundi de Pâques'],
|
const currentYear = new Date().getUTCFullYear();
|
||||||
['2026-05-01', 'Fête du travail'],
|
const years = [currentYear - 1, currentYear, currentYear + 1, currentYear + 2];
|
||||||
['2026-05-08', 'Victoire 1945'],
|
let holidayCount = 0;
|
||||||
['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'],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const location of locations) {
|
for (const location of locations) {
|
||||||
for (const [date, name] of holidays2026) {
|
for (const year of years) {
|
||||||
await prisma.holiday.upsert({
|
for (const holiday of frenchHolidays(year)) {
|
||||||
where: {
|
await prisma.holiday.upsert({
|
||||||
locationId_localDate: {
|
where: {
|
||||||
locationId: location.id,
|
locationId_localDate: {
|
||||||
localDate: new Date(`${date}T00:00:00Z`),
|
locationId: location.id,
|
||||||
|
localDate: new Date(`${holiday.isoDate}T00:00:00Z`),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
update: { name: holiday.name },
|
||||||
update: { name },
|
create: {
|
||||||
create: {
|
accountId: account.id,
|
||||||
accountId: account.id,
|
locationId: location.id,
|
||||||
locationId: location.id,
|
localDate: new Date(`${holiday.isoDate}T00:00:00Z`),
|
||||||
localDate: new Date(`${date}T00:00:00Z`),
|
name: holiday.name,
|
||||||
name,
|
// Seul le 1er mai est chômé de droit. Les trois jours garantis par
|
||||||
// Les trois jours chômés garantis par la convention sont choisis par
|
// la convention sont choisis par l'employeur : ils ne se devinent
|
||||||
// l'employeur : ils ne sont pas devinés ici.
|
// pas ici.
|
||||||
isPaidOff: date === '2026-05-01',
|
isPaidOff: holiday.isoDate.slice(5) === '05-01',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
holidayCount += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Douze dimanches du maire, liste arrêtée avant le 31 décembre pour
|
// Douze dimanches du maire au plus, liste arrêtée avant le 31 décembre
|
||||||
// l'année suivante (L3132-26). Valeurs de démonstration.
|
// pour l'année suivante (L3132-26). Valeurs de démonstration.
|
||||||
const sundays = [
|
const sundays = [
|
||||||
'2026-01-11',
|
`${currentYear}-01-11`,
|
||||||
'2026-06-28',
|
`${currentYear}-06-28`,
|
||||||
'2026-07-05',
|
`${currentYear}-07-05`,
|
||||||
'2026-08-30',
|
`${currentYear}-08-30`,
|
||||||
'2026-09-06',
|
`${currentYear}-09-06`,
|
||||||
'2026-11-29',
|
`${currentYear}-11-29`,
|
||||||
'2026-12-06',
|
`${currentYear}-12-06`,
|
||||||
'2026-12-13',
|
`${currentYear}-12-13`,
|
||||||
'2026-12-20',
|
`${currentYear}-12-20`,
|
||||||
];
|
];
|
||||||
for (const date of sundays) {
|
for (const date of sundays) {
|
||||||
await prisma.authorisedSunday.upsert({
|
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');
|
console.log('→ Durées de conservation');
|
||||||
const retention = [
|
const retention = [
|
||||||
@@ -567,6 +564,130 @@ async function main() {
|
|||||||
console.log(` ${retention.length} politiques`);
|
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');
|
console.log('→ Correspondances Silae');
|
||||||
// Semées **non confirmées**, y compris quand le code se lit dans son libellé
|
// 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
|
// (`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 { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { datesBetween, isoWeekday } from '@/domain/absences/count';
|
||||||
import { Card, CardHeader } from '@/components/ui/Card';
|
import { monthOf, parseMonthParam } from '@/domain/planning/month';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
import {
|
import { getAbsenceBoard, statusLabel } from '@/server/absences/queries';
|
||||||
CALENDAR_ABSENCES,
|
|
||||||
MONTH_LABEL,
|
|
||||||
MONTH_LENGTH,
|
|
||||||
MONTH_OFFSET,
|
|
||||||
PENDING_REQUESTS,
|
|
||||||
} from '@/lib/demo/conges';
|
|
||||||
|
|
||||||
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
|
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
|
||||||
|
|
||||||
const WEEKDAYS = [
|
interface PageProps {
|
||||||
'Lundi',
|
searchParams: Promise<{ mois?: string }>;
|
||||||
'Mardi',
|
}
|
||||||
'Mercredi',
|
|
||||||
'Jeudi',
|
|
||||||
'Vendredi',
|
|
||||||
'Samedi',
|
|
||||||
'Dimanche',
|
|
||||||
];
|
|
||||||
|
|
||||||
const KIND_STYLES: Record<string, string> = {
|
const COUNTER_LABELS: Record<string, string> = {
|
||||||
cp: 'bg-info-soft text-info-soft-ink border-info',
|
PAID_LEAVE: 'Congés payés',
|
||||||
rtt: 'bg-accent-soft text-accent-soft-ink border-accent',
|
RTT: 'RTT',
|
||||||
maladie: 'bg-surface-3 text-ink-2 border-line-3',
|
COMPENSATORY_REST: 'Repos compensateur',
|
||||||
'sans-solde': 'bg-surface-2 text-ink-2 border-line-3',
|
MODULATION: 'Modulation',
|
||||||
attente: 'bg-warn-soft text-warn-soft-ink border-warn',
|
OVERTIME: 'Heures supplémentaires',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CongesPage() {
|
const STATUS_TONES: Record<string, Tone> = {
|
||||||
const cells = Array.from({ length: 42 }, (_, index) => {
|
PENDING: 'warn',
|
||||||
const dayNumber = index - MONTH_OFFSET + 1;
|
ACCEPTED: 'ok',
|
||||||
const inMonth = dayNumber >= 1 && dayNumber <= MONTH_LENGTH;
|
DECLINED: 'danger',
|
||||||
const items = inMonth
|
CANCELLED: 'neutral',
|
||||||
? CALENDAR_ABSENCES.filter(
|
EXPIRED: 'neutral',
|
||||||
(absence) => dayNumber >= absence.from && dayNumber <= absence.to,
|
};
|
||||||
)
|
|
||||||
: [];
|
export default async function CongesPage({ searchParams }: PageProps) {
|
||||||
return { index, dayNumber, inMonth, items, weekend: index % 7 > 4 };
|
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 (
|
return (
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Calendrier des absences"
|
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={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button>Exporter</Button>
|
<Link
|
||||||
<Button variant="primary">Saisir une absence</Button>
|
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>
|
<RequestForm
|
||||||
<div className="grid grid-cols-7 border-b border-line-2 bg-surface-2">
|
absenceTypes={board.absenceTypes}
|
||||||
{WEEKDAYS.map((day) => (
|
people={board.people}
|
||||||
<div
|
ownMembershipId={board.ownMembershipId}
|
||||||
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>
|
|
||||||
|
|
||||||
<Card>
|
<section id="attente" className="rounded-3 border border-line-1 bg-surface">
|
||||||
<span id="attente" />
|
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||||
<CardHeader
|
Demandes en attente
|
||||||
title="Demandes en attente"
|
{board.pending.length > 0 ? ` · ${board.pending.length}` : ''}
|
||||||
badge={<Badge tone="warn">{PENDING_REQUESTS.length}</Badge>}
|
</h2>
|
||||||
/>
|
|
||||||
<ul>
|
{board.pending.length === 0 ? (
|
||||||
{PENDING_REQUESTS.map((request) => (
|
<p className="px-4 py-6 text-sm text-ink-3">
|
||||||
<li
|
Aucune demande à traiter.
|
||||||
key={`${request.who}-${request.range}`}
|
</p>
|
||||||
className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
|
) : (
|
||||||
>
|
<ul className="divide-y divide-line-1">
|
||||||
<span className="min-w-0 flex-1">
|
{board.pending.map((request) => (
|
||||||
<span className="block text-sm font-medium">
|
<li
|
||||||
{request.who} · {request.type}
|
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>
|
||||||
<span className="block text-micro text-ink-3">
|
<span className="tnum ml-auto text-xs text-ink-2">
|
||||||
{request.note}
|
{request.days} j
|
||||||
</span>
|
</span>
|
||||||
</span>
|
{request.status === 'ACCEPTED' ? (
|
||||||
<span className="tnum flex-none text-xs text-ink-2">
|
<CancelButton timeOffId={request.id} />
|
||||||
{request.range}
|
) : null}
|
||||||
</span>
|
</li>
|
||||||
<Badge tone="neutral">{request.days}</Badge>
|
))}
|
||||||
<span className="flex flex-none gap-2">
|
</ul>
|
||||||
<Button size="sm">Refuser</Button>
|
)}
|
||||||
<Button size="sm" variant="primary">
|
</section>
|
||||||
Accepter
|
|
||||||
</Button>
|
<section className="rounded-3 border border-line-1 bg-surface">
|
||||||
</span>
|
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||||
</li>
|
Compteurs
|
||||||
))}
|
</h2>
|
||||||
</ul>
|
{board.counters.length === 0 ? (
|
||||||
</Card>
|
<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>
|
</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 { CounterStrip } from '@/components/planning/CounterStrip';
|
||||||
import { ShiftChip } from '@/components/planning/ShiftChip';
|
import { ShiftChip } from '@/components/planning/ShiftChip';
|
||||||
import { computeWeekCounters } from '@/domain/counters/week';
|
import { computeWeekCounters } from '@/domain/counters/week';
|
||||||
@@ -182,6 +183,18 @@ function Row({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative grid flex-1 grid-cols-7">
|
<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) => {
|
{days.map((day, index) => {
|
||||||
const shifts = row.days[index] ?? [];
|
const shifts = row.days[index] ?? [];
|
||||||
return (
|
return (
|
||||||
@@ -191,6 +204,13 @@ function Row({
|
|||||||
className={cx(
|
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',
|
'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',
|
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) => (
|
{shifts.map((shift) => (
|
||||||
@@ -218,3 +238,16 @@ function Row({
|
|||||||
</div>
|
</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;
|
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 {
|
export interface BoardRow {
|
||||||
/** `null` sur la ligne des besoins non couverts. */
|
/** `null` sur la ligne des besoins non couverts. */
|
||||||
membershipId: string | null;
|
membershipId: string | null;
|
||||||
@@ -44,6 +53,8 @@ export interface BoardRow {
|
|||||||
counters: WeekCounters;
|
counters: WeekCounters;
|
||||||
/** Sept cases, lundi → dimanche. */
|
/** Sept cases, lundi → dimanche. */
|
||||||
days: BoardShift[][];
|
days: BoardShift[][];
|
||||||
|
/** Absences acceptées recouvrant la semaine affichée. */
|
||||||
|
absences: BoardAbsence[];
|
||||||
unassigned: boolean;
|
unassigned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +67,15 @@ export interface RowPerson {
|
|||||||
contractMinutes: number;
|
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.
|
* Range les créneaux d'une semaine en lignes × sept colonnes.
|
||||||
*
|
*
|
||||||
@@ -70,6 +90,7 @@ export function buildRows(
|
|||||||
weekDates: string[],
|
weekDates: string[],
|
||||||
timeZone: string,
|
timeZone: string,
|
||||||
isPublished: boolean,
|
isPublished: boolean,
|
||||||
|
absences: AbsenceInput[] = [],
|
||||||
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
|
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
|
||||||
const columnOf = new Map(weekDates.map((date, index) => [date, index]));
|
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);
|
for (const column of unassignedDays) column.sort(byStartTime);
|
||||||
|
|
||||||
|
const first = weekDates[0] ?? '';
|
||||||
|
const last = weekDates[6] ?? '';
|
||||||
|
|
||||||
const rows = people.map((person) => {
|
const rows = people.map((person) => {
|
||||||
const days = byMember.get(person.membershipId) ?? emptyDays();
|
const days = byMember.get(person.membershipId) ?? emptyDays();
|
||||||
const worked = days.filter((column) => column.length > 0).length;
|
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 {
|
return {
|
||||||
membershipId: person.membershipId,
|
membershipId: person.membershipId,
|
||||||
firstName: person.firstName,
|
firstName: person.firstName,
|
||||||
@@ -139,12 +185,18 @@ export function buildRows(
|
|||||||
forfaitJours: person.forfaitJours,
|
forfaitJours: person.forfaitJours,
|
||||||
unassigned: false,
|
unassigned: false,
|
||||||
days,
|
days,
|
||||||
|
absences: rowAbsences,
|
||||||
counters: {
|
counters: {
|
||||||
contractMinutes: person.forfaitJours ? 0 : person.contractMinutes,
|
contractMinutes: person.forfaitJours ? 0 : person.contractMinutes,
|
||||||
plannedMinutes: minutesByMember.get(person.membershipId) ?? 0,
|
plannedMinutes: minutesByMember.get(person.membershipId) ?? 0,
|
||||||
// Les absences arrivent au lot suivant ; le compteur existe déjà pour
|
// Les absences comptent dans l'atteinte du contrat : une semaine
|
||||||
// que la formule d'écart ne change pas quand elles se brancheront.
|
// entièrement en congés ne doit pas apparaître en sous-réalisation.
|
||||||
absenceMinutes: 0,
|
// Leur valorisation en minutes vient du contrat, pas du planning.
|
||||||
|
absenceMinutes: absenceMinutesOf(
|
||||||
|
rowAbsences,
|
||||||
|
person.contractMinutes,
|
||||||
|
person.forfaitJours,
|
||||||
|
),
|
||||||
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
|
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
|
||||||
restDays: 7 - worked,
|
restDays: 7 - worked,
|
||||||
},
|
},
|
||||||
@@ -160,6 +212,7 @@ export function buildRows(
|
|||||||
forfaitJours: false,
|
forfaitJours: false,
|
||||||
unassigned: true,
|
unassigned: true,
|
||||||
days: unassignedDays,
|
days: unassignedDays,
|
||||||
|
absences: [],
|
||||||
counters: {
|
counters: {
|
||||||
contractMinutes: 0,
|
contractMinutes: 0,
|
||||||
plannedMinutes: unassignedDays
|
plannedMinutes: unassignedDays
|
||||||
@@ -175,6 +228,24 @@ export function buildRows(
|
|||||||
return { rows, unassignedRow };
|
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 {
|
function byStartTime(a: BoardShift, b: BoardShift): number {
|
||||||
return a.time.localeCompare(b.time);
|
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));
|
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
|
// Les constats sont lus, jamais recalculés à l'affichage : ils datent de
|
||||||
// la dernière écriture, avec la version de convention qui s'appliquait
|
// 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é.
|
// alors. Les recalculer ici les ferait diverger de ce qui a été acquitté.
|
||||||
@@ -209,6 +235,7 @@ export async function getWeekBoard(
|
|||||||
dates,
|
dates,
|
||||||
location.timezone,
|
location.timezone,
|
||||||
isPublished,
|
isPublished,
|
||||||
|
absenceInputs,
|
||||||
);
|
);
|
||||||
|
|
||||||
const teamAlerts = alerts
|
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