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:
Claude committed 2026-08-08 08:31:01 +00:00
1 parent 747f0ef86f
commit 0213375a8f
18 files changed
+3160 -228

No files matched your search

+586
View File
@@ -0,0 +1,586 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError, can } from '@/domain/access/authorize';
import {
countAbsenceDays,
findAbsenceOverlaps,
respectsNotice,
} from '@/domain/absences/count';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import type { ScopedClient } from '@/server/tenant';
/**
* Écritures d'absences — PLAN.md §7.1 et §7.2.
*
* Deux invariants gouvernent ce fichier :
*
* 1. **Le décompte est figé à la décision.** Le calendrier de l'établissement
* peut changer après coup ; un droit acquis ne doit pas bouger avec lui.
* 2. **Le registre ne se réécrit pas.** Annuler une absence acceptée n'efface
* pas la prise : cela écrit une contre-passation. Le solde revient au même
* chiffre, l'histoire reste lisible.
*/
export interface AbsenceActionState {
error?: string;
ok?: boolean;
warning?: string;
}
class ValidationError extends Error {}
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const requestInput = z.object({
membershipId: z.string().min(1),
absenceTypeId: z.string().min(1),
startDate: z.string().regex(ISO_DATE, 'Date de début invalide'),
endDate: z.string().regex(ISO_DATE, 'Date de fin invalide'),
startHalfDay: z.boolean(),
endHalfDay: z.boolean(),
comment: z.string().trim().max(500).optional(),
});
/** Contexte de décompte d'un salarié : jours fériés et rythme contractuel. */
async function countingContextFor(db: ScopedClient, membershipId: string) {
const contract = await db.userContract.findFirst({
where: { membershipId, status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
});
const holidays = contract
? await db.holiday.findMany({
where: { locationId: contract.locationId },
select: { localDate: true },
})
: [];
return {
contract,
context: {
holidays: holidays.map((holiday) =>
holiday.localDate.toISOString().slice(0, 10),
),
// Le rythme réel se déduira du planning au lot suivant ; à défaut, la
// semaine standard, qui est le cas de la quasi-totalité des contrats.
basis: 'OUVRABLES' as const,
workingWeekdays: [1, 2, 3, 4, 5, 6],
},
};
}
export async function requestTimeOffAction(
_previous: AbsenceActionState,
formData: FormData,
): Promise<AbsenceActionState> {
const parsed = requestInput.safeParse({
membershipId: formData.get('membershipId'),
absenceTypeId: formData.get('absenceTypeId'),
startDate: formData.get('startDate'),
endDate: formData.get('endDate'),
startHalfDay: formData.get('startHalfDay') === 'on',
endHalfDay: formData.get('endHalfDay') === 'on',
comment: formData.get('comment') || undefined,
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
let warning: string | undefined;
try {
await mutate('timeoff.request', async (db, actor) => {
// Demander pour autrui suppose la capacité de décider : un salarié ne
// pose pas les congés d'un collègue.
if (
parsed.data.membershipId !== actor.membershipId &&
!can(actor, 'timeoff.decide')
) {
throw new AuthorizationError('timeoff.decide');
}
if (parsed.data.endDate < parsed.data.startDate) {
throw new ValidationError(
'La date de fin précède la date de début. Rappel : la date de fin est le **dernier jour d’absence**, pas la date de reprise.',
);
}
const absenceType = await db.absenceType.findUnique({
where: { id: parsed.data.absenceTypeId },
});
if (!absenceType) throw new ValidationError("Type d'absence inconnu.");
const existing = await db.timeOff.findMany({
where: {
membershipId: parsed.data.membershipId,
status: { in: ['PENDING', 'ACCEPTED'] },
},
select: { id: true, startDate: true, endDate: true },
});
const overlaps = findAbsenceOverlaps(
{ startDate: parsed.data.startDate, endDate: parsed.data.endDate },
existing.map((entry) => ({
id: entry.id,
startDate: entry.startDate.toISOString().slice(0, 10),
endDate: entry.endDate.toISOString().slice(0, 10),
})),
);
if (overlaps.length > 0) {
throw new ValidationError(
'Une absence couvre déjà tout ou partie de cette période.',
);
}
const today = new Date().toISOString().slice(0, 10);
if (
!respectsNotice(
{ startDate: parsed.data.startDate, endDate: parsed.data.endDate },
today,
absenceType.minNoticeDays,
)
) {
// Avertir plutôt qu'interdire : un manager peut accepter une demande
// hors délai, et le préavis n'est pas une règle de sécurité.
warning = `Préavis de ${absenceType.minNoticeDays} jours non respecté.`;
}
const { contract, context } = await countingContextFor(
db,
parsed.data.membershipId,
);
const counted = countAbsenceDays(
{
startDate: parsed.data.startDate,
endDate: parsed.data.endDate,
startHalfDay: parsed.data.startHalfDay,
endHalfDay: parsed.data.endHalfDay,
},
context,
);
if (counted.total === 0) {
throw new ValidationError(
'Cette période ne contient aucun jour décomptable : jours fériés ou non travaillés uniquement.',
);
}
// Sans jour férié enregistré sur la période, le décompte les compte comme
// travaillés — et le salarié perd un jour sans que rien ne le signale.
// Mieux vaut le dire que laisser croire à un décompte complet.
const year = parsed.data.startDate.slice(0, 4);
const knownHolidays = context.holidays.filter((date) =>
date.startsWith(year),
);
if (knownHolidays.length === 0) {
warning = [
warning,
`Aucun jour férié n'est enregistré pour ${year} : le décompte les compte comme travaillés.`,
]
.filter(Boolean)
.join(' ');
}
const created = await db.timeOff.create({
data: {
membershipId: parsed.data.membershipId,
userContractId: contract?.id ?? null,
absenceTypeId: parsed.data.absenceTypeId,
startDate: new Date(`${parsed.data.startDate}T00:00:00Z`),
endDate: new Date(`${parsed.data.endDate}T00:00:00Z`),
startHalfDay: parsed.data.startHalfDay,
endHalfDay: parsed.data.endHalfDay,
countedDays: counted.total,
comment: parsed.data.comment ?? null,
requestedBy: actor.membershipId,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'timeoff.request',
entityType: 'TimeOff',
entityId: created.id,
after: {
membershipId: parsed.data.membershipId,
type: absenceType.code,
from: parsed.data.startDate,
to: parsed.data.endDate,
days: counted.total,
},
});
});
} catch (error) {
return toState(error, "Vous n'avez pas le droit de poser cette absence.");
}
revalidatePath('/conges');
return warning ? { ok: true, warning } : { ok: true };
}
const decisionInput = z.object({
timeOffId: z.string().min(1),
accept: z.boolean(),
comment: z.string().trim().max(500).optional(),
});
/**
* Accepte ou refuse une demande.
*
* L'acceptation écrit au registre : le solde bouge parce qu'une écriture a été
* posée, jamais parce qu'un champ a été décrémenté.
*/
export async function decideTimeOffAction(
_previous: AbsenceActionState,
formData: FormData,
): Promise<AbsenceActionState> {
const parsed = decisionInput.safeParse({
timeOffId: formData.get('timeOffId'),
accept: formData.get('accept') === 'true',
comment: formData.get('comment') || undefined,
});
if (!parsed.success) return { error: 'Demande introuvable.' };
try {
await mutate('timeoff.decide', async (db, actor) => {
const timeOff = await db.timeOff.findUnique({
where: { id: parsed.data.timeOffId },
include: { absenceType: true },
});
if (!timeOff) throw new AuthorizationError('timeoff.decide');
if (timeOff.status !== 'PENDING') {
throw new ValidationError('Cette demande a déjà été traitée.');
}
if (parsed.data.accept) {
// Le contrôle est refait en transaction : entre la demande et la
// décision, un autre congé a pu être accepté sur la même période.
const others = await db.timeOff.findMany({
where: {
membershipId: timeOff.membershipId,
status: 'ACCEPTED',
id: { not: timeOff.id },
},
select: { id: true, startDate: true, endDate: true },
});
const overlaps = findAbsenceOverlaps(
{
startDate: timeOff.startDate.toISOString().slice(0, 10),
endDate: timeOff.endDate.toISOString().slice(0, 10),
},
others.map((entry) => ({
id: entry.id,
startDate: entry.startDate.toISOString().slice(0, 10),
endDate: entry.endDate.toISOString().slice(0, 10),
})),
);
if (overlaps.length > 0) {
throw new ValidationError(
'Une autre absence acceptée recouvre désormais cette période.',
);
}
}
await db.timeOff.update({
where: { id: timeOff.id },
data: {
status: parsed.data.accept ? 'ACCEPTED' : 'DECLINED',
decidedBy: actor.membershipId,
decidedAt: new Date(),
decisionComment: parsed.data.comment ?? null,
},
});
if (parsed.data.accept) {
await writeTaken(db, timeOff, actor.membershipId);
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: parsed.data.accept ? 'timeoff.accept' : 'timeoff.decline',
entityType: 'TimeOff',
entityId: timeOff.id,
before: { status: timeOff.status },
after: {
status: parsed.data.accept ? 'ACCEPTED' : 'DECLINED',
days: timeOff.countedDays?.toString() ?? null,
},
reason: parsed.data.comment ?? null,
});
});
} catch (error) {
return toState(error, "Vous n'avez pas le droit de décider de cette demande.");
}
revalidatePath('/conges');
return { ok: true };
}
/**
* Annule une absence.
*
* Sur une absence acceptée, la prise est **contre-passée** et non effacée : le
* solde revient au même chiffre, et l'histoire reste lisible pour qui devra
* l'expliquer.
*/
export async function cancelTimeOffAction(
_previous: AbsenceActionState,
formData: FormData,
): Promise<AbsenceActionState> {
const timeOffId = String(formData.get('timeOffId') ?? '');
if (!timeOffId) return { error: 'Demande introuvable.' };
try {
await mutate('timeoff.request', async (db, actor) => {
const timeOff = await db.timeOff.findUnique({
where: { id: timeOffId },
include: { absenceType: true },
});
if (!timeOff) throw new AuthorizationError('timeoff.request');
const isOwn = timeOff.membershipId === actor.membershipId;
if (!isOwn && !can(actor, 'timeoff.decide')) {
throw new AuthorizationError('timeoff.decide');
}
if (timeOff.status === 'CANCELLED') {
throw new ValidationError('Cette absence est déjà annulée.');
}
if (timeOff.status === 'ACCEPTED') {
await reverseTaken(db, timeOff, actor.membershipId);
}
await db.timeOff.update({
where: { id: timeOff.id },
data: { status: 'CANCELLED' },
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'timeoff.cancel',
entityType: 'TimeOff',
entityId: timeOff.id,
before: { status: timeOff.status },
after: { status: 'CANCELLED' },
});
});
} catch (error) {
return toState(error, "Vous n'avez pas le droit d'annuler cette absence.");
}
revalidatePath('/conges');
return { ok: true };
}
const adjustInput = z.object({
membershipId: z.string().min(1),
counterType: z.enum([
'PAID_LEAVE',
'RTT',
'COMPENSATORY_REST',
'MODULATION',
'OVERTIME',
]),
quantity: z.coerce.number().refine((value) => value !== 0, {
message: 'Un ajustement de zéro ne corrige rien.',
}),
reason: z.string().trim().min(1, 'Justification obligatoire').max(500),
});
/**
* Ajustement manuel d'un compteur.
*
* La justification est **obligatoire** et refusée si vide. Un ajustement sans
* motif ne vaut pas mieux qu'une absence de trace : il laisse un écart que
* personne ne saura expliquer six mois plus tard.
*/
export async function adjustCounterAction(
_previous: AbsenceActionState,
formData: FormData,
): Promise<AbsenceActionState> {
const parsed = adjustInput.safeParse({
membershipId: formData.get('membershipId'),
counterType: formData.get('counterType'),
quantity: formData.get('quantity'),
reason: formData.get('reason') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('counters.adjust', async (db, actor) => {
const counter = await ensureCounter(
db,
parsed.data.membershipId,
parsed.data.counterType,
);
await db.ledgerOperation.create({
data: {
counterId: counter.id,
kind: 'ADJUSTMENT',
quantity: parsed.data.quantity,
unit: 'DAY',
effectiveDate: new Date(new Date().toISOString().slice(0, 10)),
sourceType: 'MANUAL',
reason: parsed.data.reason,
createdBy: actor.membershipId,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'counter.adjust',
entityType: 'Counter',
entityId: counter.id,
after: {
quantity: parsed.data.quantity,
counterType: parsed.data.counterType,
},
reason: parsed.data.reason,
});
});
} catch (error) {
return toState(error, "Vous n'avez pas le droit d'ajuster un compteur.");
}
revalidatePath('/conges');
return { ok: true };
}
/** Compteur de la période d'acquisition courante, créé au besoin. */
async function ensureCounter(
db: ScopedClient,
membershipId: string,
counterType:
| 'PAID_LEAVE'
| 'RTT'
| 'COMPENSATORY_REST'
| 'MODULATION'
| 'OVERTIME',
) {
// Période d'acquisition des congés payés : 1er juin → 31 mai, usage
// dominant en France. Le paramétrer relève du registre §12.7.
const now = new Date();
const year = now.getUTCMonth() >= 5 ? now.getUTCFullYear() : now.getUTCFullYear() - 1;
const start = new Date(`${year}-06-01T00:00:00Z`);
const end = new Date(`${year + 1}-05-31T00:00:00Z`);
const existing = await db.counter.findFirst({
where: {
membershipId,
counterType,
acquisitionPeriodStart: start,
},
});
if (existing) return existing;
return db.counter.create({
data: {
membershipId,
counterType,
acquisitionPeriodStart: start,
acquisitionPeriodEnd: end,
} as never,
});
}
interface TimeOffWithType {
id: string;
membershipId: string;
countedDays: { toString(): string } | null;
startDate: Date;
absenceType: { code: string; isPaid: boolean; countsAsWorkTime: boolean };
}
/** Écrit la prise au registre du compteur correspondant. */
async function writeTaken(
db: ScopedClient,
timeOff: TimeOffWithType,
actorMembershipId: string,
): Promise<void> {
const counterType = counterTypeFor(timeOff.absenceType.code);
// Une absence sans compteur — maladie, sans solde — ne consomme rien : elle
// s'affiche au planning et part en paie, sans toucher un solde de congés.
if (!counterType) return;
const counter = await ensureCounter(db, timeOff.membershipId, counterType);
const days = Number(timeOff.countedDays?.toString() ?? '0');
await db.ledgerOperation.create({
data: {
counterId: counter.id,
kind: 'TAKEN',
quantity: -days,
unit: 'DAY',
effectiveDate: timeOff.startDate,
sourceType: 'TIMEOFF',
sourceId: timeOff.id,
createdBy: actorMembershipId,
} as never,
});
}
/** Contre-passe la prise, sans la supprimer. */
async function reverseTaken(
db: ScopedClient,
timeOff: TimeOffWithType,
actorMembershipId: string,
): Promise<void> {
const original = await db.ledgerOperation.findFirst({
where: { sourceType: 'TIMEOFF', sourceId: timeOff.id, kind: 'TAKEN' },
});
if (!original) return;
const already = await db.ledgerOperation.findFirst({
where: { reversesId: original.id },
});
if (already) return;
await db.ledgerOperation.create({
data: {
counterId: original.counterId,
kind: 'REGULARISATION',
// Signe opposé, date du jour : antidater masquerait la correction dans
// les soldes déjà communiqués.
quantity: -Number(original.quantity.toString()),
unit: original.unit,
effectiveDate: new Date(new Date().toISOString().slice(0, 10)),
sourceType: 'TIMEOFF',
sourceId: timeOff.id,
reason: 'Annulation de l’absence',
reversesId: original.id,
createdBy: actorMembershipId,
} as never,
});
}
/**
* Compteur consommé par un type d'absence.
*
* Volontairement restreint : seuls les congés payés et les RTT consomment un
* solde. Un arrêt maladie ne se décompte d'aucun compteur — l'y imputer
* priverait le salarié de congés auxquels il a droit.
*/
function counterTypeFor(
code: string,
): 'PAID_LEAVE' | 'RTT' | 'COMPENSATORY_REST' | null {
if (code === 'CP') return 'PAID_LEAVE';
if (code === 'RTT') return 'RTT';
if (code === 'RC') return 'COMPENSATORY_REST';
return null;
}
function toState(error: unknown, denied: string): AbsenceActionState {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) return { error: denied };
throw error;
}
+263
View File
@@ -0,0 +1,263 @@
import { can } from '@/domain/access/authorize';
import { counterView, type LedgerEntry } from '@/domain/absences/ledger';
import { monthDates, monthLabel, type Month } from '@/domain/planning/month';
import { query } from '@/server/context';
/**
* Lectures des absences — PLAN.md §7.
*
* Une règle de confidentialité gouverne ce module : **un manager voit qu'un
* salarié est absent, pas pourquoi il est malade** (matrice n° 9). Le motif
* médical est une donnée de santé, catégorie particulière du RGPD. Il n'est
* donc pas chargé pour qui n'a pas la capacité de le lire — un champ absent de
* la réponse ne peut pas fuiter par le HTML ni par un journal.
*/
export interface AbsenceRequest {
id: string;
membershipId: string;
name: string;
/** Libellé du type, ou « Absence » quand le motif est masqué. */
typeLabel: string;
colorKey: string;
isSocialSecurity: boolean;
startDate: string;
endDate: string;
startHalfDay: boolean;
endHalfDay: boolean;
days: number;
status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'CANCELLED' | 'EXPIRED';
comment: string | null;
decisionComment: string | null;
requestedAt: Date;
}
export interface CounterSummary {
membershipId: string;
name: string;
counterType: string;
accrued: number;
taken: number;
balance: number;
projected: number;
}
export interface AbsenceBoard {
month: Month;
label: string;
monthParam: string;
previousParam: string;
nextParam: string;
days: string[];
pending: AbsenceRequest[];
inMonth: AbsenceRequest[];
counters: CounterSummary[];
absenceTypes: Array<{ id: string; code: string; name: string }>;
people: Array<{ membershipId: string; name: string }>;
canDecide: boolean;
canAdjust: boolean;
ownMembershipId: string;
}
const STATUS_LABEL = {
PENDING: 'En attente',
ACCEPTED: 'Acceptée',
DECLINED: 'Refusée',
CANCELLED: 'Annulée',
EXPIRED: 'Expirée',
} as const;
export function statusLabel(status: AbsenceRequest['status']): string {
return STATUS_LABEL[status];
}
export async function getAbsenceBoard(month: Month): Promise<AbsenceBoard> {
return query('timeoff.view_own', async (db, actor) => {
const canSeeOthers = can(actor, 'timeoff.view_others');
const canDecide = can(actor, 'timeoff.decide');
const canAdjust = can(actor, 'counters.adjust');
// Le motif médical suit les documents du dossier : qui peut lire un arrêt
// de travail peut lire sa nature.
const canSeeMedical = can(actor, 'members.documents.view');
const dates = monthDates(month);
const first = dates[0] as string;
const last = dates[dates.length - 1] as string;
const scope = canSeeOthers
? {}
: { membershipId: actor.membershipId };
const timeOffs = await db.timeOff.findMany({
where: {
...scope,
OR: [
{
startDate: {
gte: new Date(`${first}T00:00:00Z`),
lte: new Date(`${last}T00:00:00Z`),
},
},
{
endDate: {
gte: new Date(`${first}T00:00:00Z`),
lte: new Date(`${last}T00:00:00Z`),
},
},
{ status: 'PENDING' },
],
},
include: { absenceType: true },
orderBy: { startDate: 'asc' },
});
const memberIds = [
...new Set(timeOffs.map((entry) => entry.membershipId)),
actor.membershipId,
];
const members = await db.membership.findMany({
where: canSeeOthers ? {} : { id: { in: memberIds } },
include: { profile: { select: { firstName: true, lastName: true } } },
});
const nameById = new Map(
members.map((member) => [
member.id,
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
]),
);
const toRequest = (entry: (typeof timeOffs)[number]): AbsenceRequest => ({
id: entry.id,
membershipId: entry.membershipId,
name: nameById.get(entry.membershipId) ?? 'Salarié',
// Un arrêt maladie s'affiche « Absence » pour qui n'a pas le droit d'en
// connaître la nature.
typeLabel:
entry.absenceType.isSocialSecurity && !canSeeMedical
? 'Absence'
: entry.absenceType.name,
colorKey: entry.absenceType.colorKey,
isSocialSecurity: entry.absenceType.isSocialSecurity,
startDate: entry.startDate.toISOString().slice(0, 10),
endDate: entry.endDate.toISOString().slice(0, 10),
startHalfDay: entry.startHalfDay,
endHalfDay: entry.endHalfDay,
days: Number(entry.countedDays?.toString() ?? '0'),
status: entry.status,
comment:
entry.absenceType.isSocialSecurity && !canSeeMedical
? null
: entry.comment,
decisionComment: entry.decisionComment,
requestedAt: entry.requestedAt,
});
const requests = timeOffs.map(toRequest);
const pending = requests.filter((entry) => entry.status === 'PENDING');
const inMonth = requests.filter(
(entry) => entry.startDate <= last && entry.endDate >= first,
);
const counters = await db.counter.findMany({
where: canSeeOthers ? {} : { membershipId: actor.membershipId },
include: { operations: true },
});
const { formatMonthParam, nextMonth, previousMonth } = await import(
'@/domain/planning/month'
);
const absenceTypes = await db.absenceType.findMany({
where: { archivedAt: null },
select: { id: true, code: true, name: true },
orderBy: { code: 'asc' },
});
return {
month,
label: monthLabel(month),
monthParam: formatMonthParam(month),
previousParam: formatMonthParam(previousMonth(month)),
nextParam: formatMonthParam(nextMonth(month)),
days: dates,
pending,
inMonth,
counters: counters.map((counter) => {
const entries: LedgerEntry[] = counter.operations.map((operation) => ({
kind: operation.kind,
quantity: Number(operation.quantity.toString()),
unit: operation.unit,
effectiveDate: operation.effectiveDate.toISOString().slice(0, 10),
}));
// La projection des acquisitions restantes arrive avec la clôture des
// périodes de paie (WP-07) : d'ici là, le prévisionnel vaut le courant.
const view = counterView(entries, 0);
return {
membershipId: counter.membershipId,
name: nameById.get(counter.membershipId) ?? 'Salarié',
counterType: counter.counterType,
accrued: view.accrued,
taken: view.taken,
balance: view.balance,
projected: view.projected,
};
}),
absenceTypes,
people: canSeeOthers
? members
.map((member) => ({
membershipId: member.id,
name: nameById.get(member.id) ?? member.employeeNumber,
}))
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
: [
{
membershipId: actor.membershipId,
name: nameById.get(actor.membershipId) ?? 'Moi',
},
],
canDecide,
canAdjust,
ownMembershipId: actor.membershipId,
};
});
}
/** Absences acceptées d'une période, pour la grille de planning. */
export async function acceptedAbsencesBetween(
membershipIds: string[],
from: string,
to: string,
): Promise<
Array<{
membershipId: string;
startDate: string;
endDate: string;
label: string;
colorKey: string;
}>
> {
return query('planning.view', async (db) => {
const rows = await db.timeOff.findMany({
where: {
membershipId: { in: membershipIds },
status: 'ACCEPTED',
startDate: { lte: new Date(`${to}T00:00:00Z`) },
endDate: { gte: new Date(`${from}T00:00:00Z`) },
},
include: { absenceType: true },
});
return rows.map((row) => ({
membershipId: row.membershipId,
startDate: row.startDate.toISOString().slice(0, 10),
endDate: row.endDate.toISOString().slice(0, 10),
// La grille de planning n'a jamais besoin du motif médical : « Absence »
// suffit à ne pas planifier quelqu'un.
label: row.absenceType.isSocialSecurity
? 'Absence'
: row.absenceType.name,
colorKey: row.absenceType.colorKey,
}));
});
}
+27
View File
@@ -178,6 +178,32 @@ export async function getWeekBoard(
const people = await loadPeople(db, teams.map((team) => team.id));
// Les absences acceptées s'affichent sur la grille : planifier quelqu'un
// qui est en congé est l'erreur que cette bande empêche.
const allMemberIds = [...people.values()]
.flat()
.map((person) => person.membershipId);
const absences = await db.timeOff.findMany({
where: {
membershipId: { in: allMemberIds },
status: 'ACCEPTED',
startDate: { lte: new Date(`${dates[6]}T00:00:00Z`) },
endDate: { gte: new Date(`${dates[0]}T00:00:00Z`) },
},
include: { absenceType: true },
});
const absenceInputs = absences.map((absence) => ({
membershipId: absence.membershipId,
startDate: absence.startDate.toISOString().slice(0, 10),
endDate: absence.endDate.toISOString().slice(0, 10),
// La grille n'a jamais besoin du motif médical : « Absence » suffit à
// ne pas planifier quelqu'un, et c'est une donnée de santé.
label: absence.absenceType.isSocialSecurity
? 'Absence'
: absence.absenceType.name,
colorKey: absence.absenceType.colorKey,
}));
// Les constats sont lus, jamais recalculés à l'affichage : ils datent de
// la dernière écriture, avec la version de convention qui s'appliquait
// alors. Les recalculer ici les ferait diverger de ce qui a été acquitté.
@@ -209,6 +235,7 @@ export async function getWeekBoard(
dates,
location.timezone,
isPublished,
absenceInputs,
);
const teamAlerts = alerts