WP-06 : absences, registre de compteurs, calendrier
Le dernier module de démonstration disparaît : `src/lib/demo/` ne contient plus que l'aperçu. Le décompte, là où l'on se trompe - **`endDate` est le dernier jour d'absence, pas la date de reprise.** 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. Le champ du formulaire s'appelle « Dernier jour d'absence », et un test couvre explicitement la confusion. - **Un jour férié dans un congé ne se décompte pas**, sans que l'utilisateur ait à scinder sa demande. Exiger la scission, c'est lui déplacer la charge d'un calcul que l'outil sait faire. - Le rythme du contrat est respecté : un temps partiel qui ne travaille jamais le mercredi ne consomme pas de congé ce jour-là. - Ouvrables ou ouvrés est un **paramètre**, pas une constante : se tromper fausse tous les soldes de la même façon. Jours fériés calculés, pas listés Une table écrite à la main est juste l'année où on l'écrit et fausse dès la suivante — et un férié manquant se décompte comme un jour de congé, sans que personne ne le voie. Les onze jours légaux sont donc calculés, Pâques comprise (algorithme grégorien anonyme, vérifié sur quatre années de référence). Quand aucun férié n'est enregistré pour l'année demandée, la demande passe mais l'écran le dit : mieux vaut l'annoncer que laisser croire à un décompte complet. Le registre est la source de vérité - Aucun solde n'est stocké : 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. - `UPDATE` et `DELETE` sont refusés par un trigger PostgreSQL, pas seulement par l'application : une règle applicative finit par être contournée par un script de reprise. Les tests d'intégration écrivent directement en base pour le prouver. - Annuler une absence acceptée **contre-passe** la prise au lieu de l'effacer, à la date de la correction — antidater masquerait la correction dans les soldes déjà communiqués. `reversesId` est unique : contre-passer deux fois ferait repartir le solde dans l'autre sens. - Un ajustement manuel sans justification est refusé. Confidentialité du motif médical Un manager voit qu'un salarié est absent, pas de quoi il souffre (matrice n° 9). Le motif d'un arrêt n'est **pas chargé** pour qui n'a pas la capacité de le lire — un champ absent de la réponse ne peut fuiter ni par le HTML ni par un journal. Sur la grille de planning, l'absence s'affiche « Absence » : c'est suffisant pour ne pas planifier quelqu'un. Acquisition 2,5 jours ouvrables par mois travaillé ; 2 jours par mois d'arrêt maladie non professionnelle, plafonnés à 24 par an — droit issu de la réforme de 2024, dont l'oubli prive le salarié d'un droit acquis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
18 files changed
+3160
-228
No files matched your search
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Décompte des jours d'absence — PLAN.md §7.2.
|
||||
*
|
||||
* Trois règles gouvernent ce module, et chacune correspond à une erreur qui
|
||||
* coûte cher :
|
||||
*
|
||||
* 1. **`endDate` est le dernier jour d'absence, pas la date de reprise.** La
|
||||
* confusion est la plus fréquente du domaine : elle décompte un jour de
|
||||
* trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au
|
||||
* solde, des mois plus tard.
|
||||
* 2. **Un jour férié dans un congé ne se décompte pas** — et l'utilisateur n'a
|
||||
* pas à scinder sa demande pour l'obtenir. Exiger la scission, c'est
|
||||
* déplacer sur lui la charge d'un calcul que l'outil sait faire.
|
||||
* 3. **Les jours non travaillés ne se décomptent pas.** Un temps partiel qui
|
||||
* ne travaille jamais le mercredi ne consomme pas de congé ce jour-là.
|
||||
*/
|
||||
|
||||
export interface AbsenceRange {
|
||||
/** Premier jour d'absence, date civile `AAAA-MM-JJ`. */
|
||||
startDate: string;
|
||||
/** **Dernier jour d'absence**, jamais la date de reprise. */
|
||||
endDate: string;
|
||||
startHalfDay?: boolean;
|
||||
endHalfDay?: boolean;
|
||||
}
|
||||
|
||||
export interface CountingContext {
|
||||
/** Jours fériés de l'établissement, dates civiles. */
|
||||
holidays: string[];
|
||||
/**
|
||||
* Jours de la semaine travaillés selon le rythme du contrat.
|
||||
* 1 = lundi … 7 = dimanche. Par défaut du lundi au vendredi.
|
||||
*/
|
||||
workingWeekdays?: number[];
|
||||
/**
|
||||
* Décompte en jours **ouvrables** (lundi–samedi) ou **ouvrés**
|
||||
* (lundi–vendredi). Le droit du travail acquiert en ouvrables ; beaucoup
|
||||
* d'entreprises décomptent en ouvrés. Le choix est un paramètre, pas une
|
||||
* constante — se tromper fausse tous les soldes de la même façon.
|
||||
*/
|
||||
basis?: 'OUVRABLES' | 'OUVRES';
|
||||
}
|
||||
|
||||
const DEFAULT_WORKING_WEEKDAYS = [1, 2, 3, 4, 5];
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** Jour ISO de la semaine : 1 = lundi … 7 = dimanche. */
|
||||
export function isoWeekday(isoDate: string): number {
|
||||
const day = new Date(`${isoDate}T00:00:00Z`).getUTCDay();
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
/** Les dates civiles couvertes par la période, bornes incluses. */
|
||||
export function datesBetween(startDate: string, endDate: string): string[] {
|
||||
const start = new Date(`${startDate}T00:00:00Z`).getTime();
|
||||
const end = new Date(`${endDate}T00:00:00Z`).getTime();
|
||||
if (end < start) return [];
|
||||
|
||||
const dates: string[] = [];
|
||||
for (let time = start; time <= end; time += DAY_MS) {
|
||||
dates.push(new Date(time).toISOString().slice(0, 10));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
export interface CountedDay {
|
||||
isoDate: string;
|
||||
/** 1 pour une journée pleine, 0,5 pour une demi-journée, 0 si non décompté. */
|
||||
quantity: number;
|
||||
reason: 'WORKED' | 'HOLIDAY' | 'NOT_WORKED' | 'HALF_DAY';
|
||||
}
|
||||
|
||||
/**
|
||||
* Détaille le décompte jour par jour.
|
||||
*
|
||||
* Le détail est rendu, pas seulement le total : quand un salarié conteste son
|
||||
* solde, « 8 jours » ne se discute pas, la liste des jours retenus si.
|
||||
*/
|
||||
export function countAbsenceDays(
|
||||
range: AbsenceRange,
|
||||
context: CountingContext,
|
||||
): { total: number; days: CountedDay[] } {
|
||||
const holidays = new Set(context.holidays);
|
||||
const basis = context.basis ?? 'OUVRABLES';
|
||||
const contractDays = new Set(
|
||||
context.workingWeekdays ?? DEFAULT_WORKING_WEEKDAYS,
|
||||
);
|
||||
// Les jours ouvrables incluent le samedi ; les ouvrés s'arrêtent au vendredi.
|
||||
const basisMax = basis === 'OUVRABLES' ? 6 : 5;
|
||||
|
||||
const dates = datesBetween(range.startDate, range.endDate);
|
||||
const days: CountedDay[] = dates.map((isoDate) => {
|
||||
const weekday = isoWeekday(isoDate);
|
||||
|
||||
if (holidays.has(isoDate)) {
|
||||
return { isoDate, quantity: 0, reason: 'HOLIDAY' as const };
|
||||
}
|
||||
if (weekday > basisMax || !contractDays.has(weekday)) {
|
||||
return { isoDate, quantity: 0, reason: 'NOT_WORKED' as const };
|
||||
}
|
||||
return { isoDate, quantity: 1, reason: 'WORKED' as const };
|
||||
});
|
||||
|
||||
// Les demi-journées ne s'appliquent qu'aux bornes, et seulement si elles
|
||||
// sont effectivement décomptées : une demi-journée sur un jour férié ne
|
||||
// retire rien de plus.
|
||||
const first = days[0];
|
||||
const last = days[days.length - 1];
|
||||
|
||||
if (range.startHalfDay && first && first.quantity === 1) {
|
||||
first.quantity = 0.5;
|
||||
first.reason = 'HALF_DAY';
|
||||
}
|
||||
if (range.endHalfDay && last && last.quantity === 1) {
|
||||
// Une absence d'un seul jour marquée demi-journée des deux côtés reste une
|
||||
// demi-journée, pas zéro.
|
||||
last.quantity = last === first ? 0.5 : 0.5;
|
||||
last.reason = 'HALF_DAY';
|
||||
}
|
||||
|
||||
const total = days.reduce((sum, day) => sum + day.quantity, 0);
|
||||
return { total, days };
|
||||
}
|
||||
|
||||
/** Total seul, quand le détail n'est pas nécessaire. */
|
||||
export function absenceDayCount(
|
||||
range: AbsenceRange,
|
||||
context: CountingContext,
|
||||
): number {
|
||||
return countAbsenceDays(range, context).total;
|
||||
}
|
||||
|
||||
export interface OverlapCandidate {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absences acceptées qui recouvrent la période demandée.
|
||||
*
|
||||
* Bornes **inclusives des deux côtés**, parce que `endDate` est un jour
|
||||
* d'absence. Une comparaison exclusive laisserait passer deux congés qui se
|
||||
* touchent le même jour.
|
||||
*/
|
||||
export function findAbsenceOverlaps(
|
||||
range: Pick<AbsenceRange, 'startDate' | 'endDate'>,
|
||||
existing: OverlapCandidate[],
|
||||
ignoreId?: string,
|
||||
): OverlapCandidate[] {
|
||||
return existing.filter(
|
||||
(candidate) =>
|
||||
candidate.id !== ignoreId &&
|
||||
candidate.startDate <= range.endDate &&
|
||||
candidate.endDate >= range.startDate,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Une demande porte-t-elle sur le passé ?
|
||||
*
|
||||
* Informatif : une régularisation a posteriori est légitime — un arrêt maladie
|
||||
* se déclare toujours après coup. C'est l'écran qui décide d'avertir, pas le
|
||||
* domaine qui interdit.
|
||||
*/
|
||||
export function isRetroactive(range: AbsenceRange, today: string): boolean {
|
||||
return range.startDate < today;
|
||||
}
|
||||
|
||||
/** Le préavis conventionnel est-il respecté ? */
|
||||
export function respectsNotice(
|
||||
range: AbsenceRange,
|
||||
today: string,
|
||||
minNoticeDays: number | null,
|
||||
): boolean {
|
||||
if (minNoticeDays === null || minNoticeDays <= 0) return true;
|
||||
const days = datesBetween(today, range.startDate).length - 1;
|
||||
return days >= minNoticeDays;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Jours fériés légaux français.
|
||||
*
|
||||
* Calculés plutôt que listés : une table écrite à la main est juste l'année où
|
||||
* on l'écrit et fausse dès la suivante. Un jour férié manquant se décompte
|
||||
* comme un jour de congé — le salarié perd un jour, et personne ne le voit.
|
||||
*
|
||||
* Ne couvre pas l'Alsace-Moselle, qui compte deux jours de plus (Vendredi saint
|
||||
* et 26 décembre). Ils s'ajoutent par établissement, comme le reste : le
|
||||
* référentiel `Holiday` est porté par la `Location`, précisément pour qu'une
|
||||
* chaîne puisse ouvrir un magasin de l'autre côté de la limite.
|
||||
*/
|
||||
|
||||
export interface FrenchHoliday {
|
||||
isoDate: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Dimanche de Pâques, algorithme grégorien anonyme.
|
||||
*
|
||||
* Trois des onze jours fériés en dépendent : lundi de Pâques, Ascension et
|
||||
* lundi de Pentecôte. Se tromper de date décale trois jours dans l'année.
|
||||
*/
|
||||
export function easterSunday(year: number): Date {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
function iso(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function shift(date: Date, days: number): Date {
|
||||
return new Date(date.getTime() + days * DAY_MS);
|
||||
}
|
||||
|
||||
/** Les onze jours fériés légaux d'une année, triés. */
|
||||
export function frenchHolidays(year: number): FrenchHoliday[] {
|
||||
const easter = easterSunday(year);
|
||||
|
||||
return [
|
||||
{ isoDate: `${year}-01-01`, name: 'Jour de l’an' },
|
||||
{ isoDate: iso(shift(easter, 1)), name: 'Lundi de Pâques' },
|
||||
{ isoDate: `${year}-05-01`, name: 'Fête du travail' },
|
||||
{ isoDate: `${year}-05-08`, name: 'Victoire 1945' },
|
||||
{ isoDate: iso(shift(easter, 39)), name: 'Ascension' },
|
||||
{ isoDate: iso(shift(easter, 50)), name: 'Lundi de Pentecôte' },
|
||||
{ isoDate: `${year}-07-14`, name: 'Fête nationale' },
|
||||
{ isoDate: `${year}-08-15`, name: 'Assomption' },
|
||||
{ isoDate: `${year}-11-01`, name: 'Toussaint' },
|
||||
{ isoDate: `${year}-11-11`, name: 'Armistice 1918' },
|
||||
{ isoDate: `${year}-12-25`, name: 'Noël' },
|
||||
].sort((a, b) => a.isoDate.localeCompare(b.isoDate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le 1er mai est le seul jour férié **chômé de droit**.
|
||||
*
|
||||
* Les autres ne le sont que par convention ou usage : l'IDCC 1517 en garantit
|
||||
* trois par an, choisis par l'employeur. Le supposer pour les onze donnerait
|
||||
* des soldes de congés faux dans le sens favorable au salarié, ce qui n'est pas
|
||||
* plus acceptable que l'inverse.
|
||||
*/
|
||||
export function isLabourDay(isoDate: string): boolean {
|
||||
return isoDate.slice(5) === '05-01';
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Registre des compteurs — PLAN.md §7.1.
|
||||
*
|
||||
* `LedgerOperation` est la source de vérité, et le solde est la **somme** des
|
||||
* écritures. Aucun solde n'est stocké : un solde stocké se désynchronise, et la
|
||||
* désynchronisation ne se voit qu'au moment où un salarié conteste.
|
||||
*
|
||||
* Corollaire : **aucune écriture n'est jamais modifiée ni supprimée.** Une
|
||||
* correction s'écrit — contre-passation puis nouvelle écriture — elle ne se
|
||||
* réécrit pas. Un trigger PostgreSQL l'impose, parce qu'une règle appliquée
|
||||
* seulement dans l'application finit par être contournée par un script.
|
||||
*/
|
||||
|
||||
export type LedgerKind =
|
||||
| 'ACCRUAL'
|
||||
| 'TAKEN'
|
||||
| 'ADJUSTMENT'
|
||||
| 'CARRY_OVER'
|
||||
| 'EXPIRY'
|
||||
| 'REGULARISATION';
|
||||
|
||||
export type LedgerUnit = 'DAY' | 'HOUR';
|
||||
|
||||
export interface LedgerEntry {
|
||||
kind: LedgerKind;
|
||||
/** **Signé** : positif acquiert, négatif consomme. */
|
||||
quantity: number;
|
||||
unit: LedgerUnit;
|
||||
effectiveDate: string;
|
||||
}
|
||||
|
||||
/** Le solde est la somme, jamais une valeur stockée. */
|
||||
export function balanceOf(entries: LedgerEntry[]): number {
|
||||
return round(entries.reduce((sum, entry) => sum + entry.quantity, 0));
|
||||
}
|
||||
|
||||
/** Solde à une date donnée, écritures postérieures exclues. */
|
||||
export function balanceAt(entries: LedgerEntry[], isoDate: string): number {
|
||||
return balanceOf(entries.filter((entry) => entry.effectiveDate <= isoDate));
|
||||
}
|
||||
|
||||
export interface CounterView {
|
||||
/** Acquis sur la période. */
|
||||
accrued: number;
|
||||
/** Pris, en valeur absolue. */
|
||||
taken: number;
|
||||
/** Solde courant. */
|
||||
balance: number;
|
||||
/** Solde projeté en fin de période d'acquisition. */
|
||||
projected: number;
|
||||
unit: LedgerUnit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue d'un compteur, courant et prévisionnel.
|
||||
*
|
||||
* Le prévisionnel projette les acquisitions **restant à courir** sur la période.
|
||||
* C'est ce que le salarié veut savoir avant de poser ses congés d'été : « ce
|
||||
* que j'aurai », pas « ce que j'ai ».
|
||||
*/
|
||||
export function counterView(
|
||||
entries: LedgerEntry[],
|
||||
remainingAccrual: number,
|
||||
unit: LedgerUnit = 'DAY',
|
||||
): CounterView {
|
||||
const accrued = round(
|
||||
entries
|
||||
.filter((entry) => entry.quantity > 0)
|
||||
.reduce((sum, entry) => sum + entry.quantity, 0),
|
||||
);
|
||||
const taken = round(
|
||||
Math.abs(
|
||||
entries
|
||||
.filter((entry) => entry.kind === 'TAKEN')
|
||||
.reduce((sum, entry) => sum + entry.quantity, 0),
|
||||
),
|
||||
);
|
||||
const balance = balanceOf(entries);
|
||||
|
||||
return {
|
||||
accrued,
|
||||
taken,
|
||||
balance,
|
||||
projected: round(balance + remainingAccrual),
|
||||
unit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquisition de congés payés — PLAN.md §12.3.
|
||||
*
|
||||
* 2,5 jours ouvrables par mois de travail effectif. Pendant un arrêt maladie
|
||||
* **non professionnelle**, l'acquisition continue à 2 jours par mois, dans la
|
||||
* limite de 24 jours par an : c'est le droit issu de la réforme de 2024, et
|
||||
* l'oublier prive le salarié d'un droit acquis.
|
||||
*
|
||||
* Les valeurs viennent des paramètres, pas d'ici.
|
||||
*/
|
||||
export interface AccrualParameters {
|
||||
/** Jours acquis par mois de travail effectif. */
|
||||
perWorkedMonth: number;
|
||||
/** Jours acquis par mois d'arrêt maladie non professionnelle. */
|
||||
perSickMonth: number;
|
||||
/** Plafond annuel d'acquisition pendant maladie. */
|
||||
sickAnnualCap: number;
|
||||
}
|
||||
|
||||
export const PAID_LEAVE_ACCRUAL: AccrualParameters = {
|
||||
perWorkedMonth: 2.5,
|
||||
perSickMonth: 2,
|
||||
sickAnnualCap: 24,
|
||||
};
|
||||
|
||||
export function accrualForMonth(
|
||||
worked: boolean,
|
||||
sickMonths: number,
|
||||
parameters: AccrualParameters = PAID_LEAVE_ACCRUAL,
|
||||
): number {
|
||||
if (worked) return parameters.perWorkedMonth;
|
||||
|
||||
const alreadyAccrued = sickMonths * parameters.perSickMonth;
|
||||
const remaining = parameters.sickAnnualCap - alreadyAccrued;
|
||||
return round(Math.max(0, Math.min(parameters.perSickMonth, remaining)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Écriture inverse d'une écriture existante.
|
||||
*
|
||||
* Même quantité, signe opposé. La date d'effet est celle de la correction, pas
|
||||
* celle de l'écriture d'origine : antidater masquerait la correction dans les
|
||||
* soldes déjà communiqués.
|
||||
*/
|
||||
export function reversalOf(entry: LedgerEntry, onDate: string): LedgerEntry {
|
||||
return {
|
||||
kind: 'REGULARISATION',
|
||||
quantity: -entry.quantity,
|
||||
unit: entry.unit,
|
||||
effectiveDate: onDate,
|
||||
};
|
||||
}
|
||||
|
||||
/** Quatre décimales : la précision du ledger, pas celle de l'affichage. */
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 10_000) / 10_000;
|
||||
}
|
||||
@@ -34,6 +34,15 @@ export interface BoardShift {
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface BoardAbsence {
|
||||
label: string;
|
||||
colorKey: string;
|
||||
/** Index de colonne du premier jour visible, 0 = lundi. */
|
||||
startDay: number;
|
||||
/** Nombre de jours couverts dans la semaine affichée. */
|
||||
span: number;
|
||||
}
|
||||
|
||||
export interface BoardRow {
|
||||
/** `null` sur la ligne des besoins non couverts. */
|
||||
membershipId: string | null;
|
||||
@@ -44,6 +53,8 @@ export interface BoardRow {
|
||||
counters: WeekCounters;
|
||||
/** Sept cases, lundi → dimanche. */
|
||||
days: BoardShift[][];
|
||||
/** Absences acceptées recouvrant la semaine affichée. */
|
||||
absences: BoardAbsence[];
|
||||
unassigned: boolean;
|
||||
}
|
||||
|
||||
@@ -56,6 +67,15 @@ export interface RowPerson {
|
||||
contractMinutes: number;
|
||||
}
|
||||
|
||||
export interface AbsenceInput {
|
||||
membershipId: string;
|
||||
startDate: string;
|
||||
/** Dernier jour d'absence, pas la date de reprise. */
|
||||
endDate: string;
|
||||
label: string;
|
||||
colorKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Range les créneaux d'une semaine en lignes × sept colonnes.
|
||||
*
|
||||
@@ -70,6 +90,7 @@ export function buildRows(
|
||||
weekDates: string[],
|
||||
timeZone: string,
|
||||
isPublished: boolean,
|
||||
absences: AbsenceInput[] = [],
|
||||
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
|
||||
const columnOf = new Map(weekDates.map((date, index) => [date, index]));
|
||||
|
||||
@@ -128,9 +149,34 @@ export function buildRows(
|
||||
}
|
||||
for (const column of unassignedDays) column.sort(byStartTime);
|
||||
|
||||
const first = weekDates[0] ?? '';
|
||||
const last = weekDates[6] ?? '';
|
||||
|
||||
const rows = people.map((person) => {
|
||||
const days = byMember.get(person.membershipId) ?? emptyDays();
|
||||
const worked = days.filter((column) => column.length > 0).length;
|
||||
|
||||
// L'absence est **rognée** aux bornes de la semaine affichée : une absence
|
||||
// de trois semaines doit apparaître entière sur chacune, pas déborder.
|
||||
const rowAbsences = absences
|
||||
.filter(
|
||||
(absence) =>
|
||||
absence.membershipId === person.membershipId &&
|
||||
absence.startDate <= last &&
|
||||
absence.endDate >= first,
|
||||
)
|
||||
.map((absence) => {
|
||||
const startDay = Math.max(0, weekDates.indexOf(absence.startDate));
|
||||
const endIndex = weekDates.indexOf(absence.endDate);
|
||||
const endDay = endIndex === -1 ? 6 : endIndex;
|
||||
return {
|
||||
label: absence.label,
|
||||
colorKey: absence.colorKey,
|
||||
startDay: absence.startDate < first ? 0 : startDay,
|
||||
span: endDay - (absence.startDate < first ? 0 : startDay) + 1,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
membershipId: person.membershipId,
|
||||
firstName: person.firstName,
|
||||
@@ -139,12 +185,18 @@ export function buildRows(
|
||||
forfaitJours: person.forfaitJours,
|
||||
unassigned: false,
|
||||
days,
|
||||
absences: rowAbsences,
|
||||
counters: {
|
||||
contractMinutes: person.forfaitJours ? 0 : person.contractMinutes,
|
||||
plannedMinutes: minutesByMember.get(person.membershipId) ?? 0,
|
||||
// Les absences arrivent au lot suivant ; le compteur existe déjà pour
|
||||
// que la formule d'écart ne change pas quand elles se brancheront.
|
||||
absenceMinutes: 0,
|
||||
// Les absences comptent dans l'atteinte du contrat : une semaine
|
||||
// entièrement en congés ne doit pas apparaître en sous-réalisation.
|
||||
// Leur valorisation en minutes vient du contrat, pas du planning.
|
||||
absenceMinutes: absenceMinutesOf(
|
||||
rowAbsences,
|
||||
person.contractMinutes,
|
||||
person.forfaitJours,
|
||||
),
|
||||
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
|
||||
restDays: 7 - worked,
|
||||
},
|
||||
@@ -160,6 +212,7 @@ export function buildRows(
|
||||
forfaitJours: false,
|
||||
unassigned: true,
|
||||
days: unassignedDays,
|
||||
absences: [],
|
||||
counters: {
|
||||
contractMinutes: 0,
|
||||
plannedMinutes: unassignedDays
|
||||
@@ -175,6 +228,24 @@ export function buildRows(
|
||||
return { rows, unassignedRow };
|
||||
}
|
||||
|
||||
/**
|
||||
* Durée créditée par une absence, en minutes.
|
||||
*
|
||||
* Proportionnelle au contrat sur une base de cinq jours ouvrés : un salarié à
|
||||
* 35 h absent trois jours est crédité de 21 h. Le calcul reste ici, dans la
|
||||
* mise en forme, parce qu'il ne sert qu'à l'écart affiché — la paie, elle, part
|
||||
* du décompte en jours figé à la décision.
|
||||
*/
|
||||
function absenceMinutesOf(
|
||||
absences: BoardAbsence[],
|
||||
contractMinutes: number,
|
||||
forfaitJours: boolean,
|
||||
): number {
|
||||
if (forfaitJours || contractMinutes <= 0) return 0;
|
||||
const days = absences.reduce((total, absence) => total + absence.span, 0);
|
||||
return Math.round((contractMinutes / 5) * Math.min(days, 5));
|
||||
}
|
||||
|
||||
function byStartTime(a: BoardShift, b: BoardShift): number {
|
||||
return a.time.localeCompare(b.time);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user