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
+304
-123
@@ -1,148 +1,329 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
|
||||
import { RequestForm } from '@/components/absences/RequestForm';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||
import { datesBetween, isoWeekday } from '@/domain/absences/count';
|
||||
import { monthOf, parseMonthParam } from '@/domain/planning/month';
|
||||
import { cx } from '@/lib/cx';
|
||||
import {
|
||||
CALENDAR_ABSENCES,
|
||||
MONTH_LABEL,
|
||||
MONTH_LENGTH,
|
||||
MONTH_OFFSET,
|
||||
PENDING_REQUESTS,
|
||||
} from '@/lib/demo/conges';
|
||||
import { getAbsenceBoard, statusLabel } from '@/server/absences/queries';
|
||||
|
||||
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
|
||||
|
||||
const WEEKDAYS = [
|
||||
'Lundi',
|
||||
'Mardi',
|
||||
'Mercredi',
|
||||
'Jeudi',
|
||||
'Vendredi',
|
||||
'Samedi',
|
||||
'Dimanche',
|
||||
];
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ mois?: string }>;
|
||||
}
|
||||
|
||||
const KIND_STYLES: Record<string, string> = {
|
||||
cp: 'bg-info-soft text-info-soft-ink border-info',
|
||||
rtt: 'bg-accent-soft text-accent-soft-ink border-accent',
|
||||
maladie: 'bg-surface-3 text-ink-2 border-line-3',
|
||||
'sans-solde': 'bg-surface-2 text-ink-2 border-line-3',
|
||||
attente: 'bg-warn-soft text-warn-soft-ink border-warn',
|
||||
const COUNTER_LABELS: Record<string, string> = {
|
||||
PAID_LEAVE: 'Congés payés',
|
||||
RTT: 'RTT',
|
||||
COMPENSATORY_REST: 'Repos compensateur',
|
||||
MODULATION: 'Modulation',
|
||||
OVERTIME: 'Heures supplémentaires',
|
||||
};
|
||||
|
||||
export default function CongesPage() {
|
||||
const cells = Array.from({ length: 42 }, (_, index) => {
|
||||
const dayNumber = index - MONTH_OFFSET + 1;
|
||||
const inMonth = dayNumber >= 1 && dayNumber <= MONTH_LENGTH;
|
||||
const items = inMonth
|
||||
? CALENDAR_ABSENCES.filter(
|
||||
(absence) => dayNumber >= absence.from && dayNumber <= absence.to,
|
||||
)
|
||||
: [];
|
||||
return { index, dayNumber, inMonth, items, weekend: index % 7 > 4 };
|
||||
});
|
||||
const STATUS_TONES: Record<string, Tone> = {
|
||||
PENDING: 'warn',
|
||||
ACCEPTED: 'ok',
|
||||
DECLINED: 'danger',
|
||||
CANCELLED: 'neutral',
|
||||
EXPIRED: 'neutral',
|
||||
};
|
||||
|
||||
export default async function CongesPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
|
||||
const board = await getAbsenceBoard(month);
|
||||
|
||||
const href = (mois: string) => `/conges?mois=${mois}`;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Calendrier des absences"
|
||||
subtitle={`${MONTH_LABEL} · Nantes Atlantis · ${PENDING_REQUESTS.length} demandes en attente`}
|
||||
subtitle={`${board.label} · ${board.inMonth.length} absence${board.inMonth.length > 1 ? 's' : ''} sur le mois`}
|
||||
actions={
|
||||
<>
|
||||
<Button>Exporter</Button>
|
||||
<Button variant="primary">Saisir une absence</Button>
|
||||
<Link
|
||||
href={href(board.previousParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
← Mois précédent
|
||||
</Link>
|
||||
<Link
|
||||
href={href(board.nextParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Mois suivant →
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div className="grid grid-cols-7 border-b border-line-2 bg-surface-2">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="border-r border-line-1 px-2 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase last:border-r-0"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.index}
|
||||
className={cx(
|
||||
'min-h-24 border-r border-b border-line-1 p-1.5 last:border-r-0',
|
||||
!cell.inMonth && 'bg-surface-2',
|
||||
cell.inMonth && cell.weekend && 'bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'tnum block text-micro font-semibold',
|
||||
cell.inMonth ? 'text-ink-1' : 'text-ink-3',
|
||||
)}
|
||||
>
|
||||
{cell.inMonth ? cell.dayNumber : ''}
|
||||
</span>
|
||||
<ul className="mt-1 flex flex-col gap-0.5">
|
||||
{cell.items.slice(0, 3).map((absence) => (
|
||||
<li
|
||||
key={`${absence.who}-${absence.type}`}
|
||||
className={cx(
|
||||
'truncate rounded-1 border px-1 py-px text-micro',
|
||||
KIND_STYLES[absence.kind],
|
||||
)}
|
||||
title={`${absence.who} · ${absence.type}`}
|
||||
>
|
||||
{absence.who.split(' ')[0]} · {absence.type}
|
||||
</li>
|
||||
))}
|
||||
{cell.items.length > 3 ? (
|
||||
<li className="px-1 text-micro text-ink-3">
|
||||
+{cell.items.length - 3}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<RequestForm
|
||||
absenceTypes={board.absenceTypes}
|
||||
people={board.people}
|
||||
ownMembershipId={board.ownMembershipId}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<span id="attente" />
|
||||
<CardHeader
|
||||
title="Demandes en attente"
|
||||
badge={<Badge tone="warn">{PENDING_REQUESTS.length}</Badge>}
|
||||
/>
|
||||
<ul>
|
||||
{PENDING_REQUESTS.map((request) => (
|
||||
<li
|
||||
key={`${request.who}-${request.range}`}
|
||||
className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">
|
||||
{request.who} · {request.type}
|
||||
<section id="attente" className="rounded-3 border border-line-1 bg-surface">
|
||||
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Demandes en attente
|
||||
{board.pending.length > 0 ? ` · ${board.pending.length}` : ''}
|
||||
</h2>
|
||||
|
||||
{board.pending.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucune demande à traiter.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{board.pending.map((request) => (
|
||||
<li
|
||||
key={request.id}
|
||||
className="flex flex-wrap items-center gap-3 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-56 flex-1">
|
||||
<p className="text-sm font-medium text-ink-1">
|
||||
{request.name} · {request.typeLabel}
|
||||
</p>
|
||||
<p className="text-micro text-ink-3">
|
||||
du {frenchDate(request.startDate)} au{' '}
|
||||
{frenchDate(request.endDate)} ·{' '}
|
||||
<span className="tnum">{request.days}</span> jour
|
||||
{request.days > 1 ? 's' : ''} décompté
|
||||
{request.days > 1 ? 's' : ''}
|
||||
</p>
|
||||
{request.comment ? (
|
||||
<p className="mt-0.5 text-micro text-ink-2">
|
||||
« {request.comment} »
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{board.canDecide ? (
|
||||
<DecisionButtons timeOffId={request.id} />
|
||||
) : (
|
||||
<CancelButton timeOffId={request.id} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<MonthCalendar board={board} />
|
||||
|
||||
<section className="rounded-3 border border-line-1 bg-surface">
|
||||
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Absences du mois
|
||||
</h2>
|
||||
{board.inMonth.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucune absence sur cette période.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{board.inMonth.map((request) => (
|
||||
<li
|
||||
key={request.id}
|
||||
className="flex flex-wrap items-center gap-3 px-4 py-2.5"
|
||||
>
|
||||
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
|
||||
{statusLabel(request.status)}
|
||||
</Badge>
|
||||
<span className="text-sm text-ink-1">{request.name}</span>
|
||||
<span className="text-sm text-ink-2">{request.typeLabel}</span>
|
||||
<span className="text-micro text-ink-3">
|
||||
{frenchDate(request.startDate)} → {frenchDate(request.endDate)}
|
||||
</span>
|
||||
<span className="block text-micro text-ink-3">
|
||||
{request.note}
|
||||
<span className="tnum ml-auto text-xs text-ink-2">
|
||||
{request.days} j
|
||||
</span>
|
||||
</span>
|
||||
<span className="tnum flex-none text-xs text-ink-2">
|
||||
{request.range}
|
||||
</span>
|
||||
<Badge tone="neutral">{request.days}</Badge>
|
||||
<span className="flex flex-none gap-2">
|
||||
<Button size="sm">Refuser</Button>
|
||||
<Button size="sm" variant="primary">
|
||||
Accepter
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
{request.status === 'ACCEPTED' ? (
|
||||
<CancelButton timeOffId={request.id} />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-3 border border-line-1 bg-surface">
|
||||
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Compteurs
|
||||
</h2>
|
||||
{board.counters.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucun compteur ouvert. Un compteur se crée à la première écriture —
|
||||
acceptation d’un congé ou ajustement manuel.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
|
||||
Salarié
|
||||
</th>
|
||||
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
|
||||
Compteur
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
|
||||
Acquis
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
|
||||
Pris
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
|
||||
Solde
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{board.counters.map((counter) => (
|
||||
<tr
|
||||
key={`${counter.membershipId}-${counter.counterType}`}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<td className="px-4 py-1.5 text-ink-1">{counter.name}</td>
|
||||
<td className="px-4 py-1.5 text-ink-2">
|
||||
{COUNTER_LABELS[counter.counterType] ?? counter.counterType}
|
||||
</td>
|
||||
<td className="tnum px-4 py-1.5 text-right text-ink-2">
|
||||
{counter.accrued}
|
||||
</td>
|
||||
<td className="tnum px-4 py-1.5 text-right text-ink-2">
|
||||
{counter.taken}
|
||||
</td>
|
||||
<td className="tnum px-4 py-1.5 text-right font-semibold text-ink-1">
|
||||
{counter.balance}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="border-t border-line-1 px-4 py-2 text-micro text-ink-3">
|
||||
Le solde est la somme des écritures du registre, jamais une valeur
|
||||
stockée : un solde stocké se désynchronise, et la désynchronisation ne
|
||||
se voit qu’au moment où un salarié conteste.
|
||||
</p>
|
||||
</section>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
/** Calendrier du mois, une colonne par jour. */
|
||||
function MonthCalendar({
|
||||
board,
|
||||
}: {
|
||||
board: Awaited<ReturnType<typeof getAbsenceBoard>>;
|
||||
}) {
|
||||
const accepted = board.inMonth.filter(
|
||||
(request) => request.status === 'ACCEPTED' || request.status === 'PENDING',
|
||||
);
|
||||
const people = [...new Set(accepted.map((request) => request.membershipId))];
|
||||
|
||||
if (people.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<caption className="sr-only">
|
||||
Calendrier des absences, {board.label}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 bg-surface-2">
|
||||
<th
|
||||
scope="col"
|
||||
className="sticky left-0 z-10 min-w-40 bg-surface-2 px-3 py-2 text-left text-micro font-semibold text-ink-3 uppercase"
|
||||
>
|
||||
Salarié
|
||||
</th>
|
||||
{board.days.map((date) => (
|
||||
<th
|
||||
key={date}
|
||||
scope="col"
|
||||
className={cx(
|
||||
'tnum w-7 px-0.5 py-2 text-center font-semibold',
|
||||
isoWeekday(date) >= 6
|
||||
? 'bg-surface-3 text-ink-2'
|
||||
: 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{Number(date.slice(8, 10))}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{people.map((membershipId) => {
|
||||
const own = accepted.filter(
|
||||
(request) => request.membershipId === membershipId,
|
||||
);
|
||||
const covered = new Map<string, (typeof own)[number]>();
|
||||
for (const request of own) {
|
||||
for (const date of datesBetween(
|
||||
request.startDate,
|
||||
request.endDate,
|
||||
)) {
|
||||
covered.set(date, request);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={membershipId}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
|
||||
>
|
||||
{own[0]?.name ?? ''}
|
||||
</th>
|
||||
{board.days.map((date) => {
|
||||
const request = covered.get(date);
|
||||
return (
|
||||
<td
|
||||
key={date}
|
||||
className={cx(
|
||||
'border-l border-line-1 px-0.5 py-1.5 text-center',
|
||||
isoWeekday(date) >= 6 && 'bg-surface-2',
|
||||
)}
|
||||
title={
|
||||
request
|
||||
? `${request.typeLabel} — ${statusLabel(request.status)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{request ? (
|
||||
<span
|
||||
className={cx(
|
||||
'block h-4 rounded-1',
|
||||
request.status === 'PENDING'
|
||||
? 'border border-dashed border-warn bg-warn-soft'
|
||||
: 'bg-info-soft',
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{request.typeLabel} le {date}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function frenchDate(isoDate: string): string {
|
||||
return isoDate.split('-').reverse().join('/');
|
||||
}
|
||||
Reference in new issue
Block a user