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,84 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
cancelTimeOffAction,
|
||||
decideTimeOffAction,
|
||||
type AbsenceActionState,
|
||||
} from '@/server/absences/actions';
|
||||
|
||||
const empty: AbsenceActionState = {};
|
||||
|
||||
/** Accepter ou refuser une demande en attente. */
|
||||
export function DecisionButtons({ timeOffId }: { timeOffId: string }) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
decideTimeOffAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="timeOffId" value={timeOffId} />
|
||||
<input
|
||||
name="comment"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
placeholder="Commentaire"
|
||||
className="h-7 w-40 rounded-2 border border-line-2 bg-surface px-2 text-xs text-ink-1"
|
||||
/>
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
name="accept"
|
||||
value="true"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={pending}
|
||||
>
|
||||
Accepter
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
name="accept"
|
||||
value="false"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
>
|
||||
Refuser
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Annulation d'une absence.
|
||||
*
|
||||
* Sur une absence acceptée, la prise est contre-passée et non effacée : le
|
||||
* solde revient au même chiffre, et l'histoire reste lisible.
|
||||
*/
|
||||
export function CancelButton({ timeOffId }: { timeOffId: string }) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
cancelTimeOffAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex items-center gap-2">
|
||||
<input type="hidden" name="timeOffId" value={timeOffId} />
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
<Button type="submit" size="sm" disabled={pending}>
|
||||
Annuler
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
requestTimeOffAction,
|
||||
type AbsenceActionState,
|
||||
} from '@/server/absences/actions';
|
||||
|
||||
const empty: AbsenceActionState = {};
|
||||
|
||||
/**
|
||||
* Demande d'absence.
|
||||
*
|
||||
* Le libellé du champ de fin dit **« dernier jour d'absence »** et non « date
|
||||
* de fin ». C'est la confusion la plus fréquente du domaine : elle décompte un
|
||||
* jour de trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au
|
||||
* solde, des mois plus tard. Un libellé exact coûte moins qu'une régularisation.
|
||||
*/
|
||||
export function RequestForm({
|
||||
absenceTypes,
|
||||
people,
|
||||
ownMembershipId,
|
||||
}: {
|
||||
absenceTypes: Array<{ id: string; code: string; name: string }>;
|
||||
people: Array<{ membershipId: string; name: string }>;
|
||||
ownMembershipId: string;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
requestTimeOffAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
||||
>
|
||||
<Field label="Salarié" htmlFor="req-member">
|
||||
<select
|
||||
id="req-member"
|
||||
name="membershipId"
|
||||
defaultValue={ownMembershipId}
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{people.map((person) => (
|
||||
<option key={person.membershipId} value={person.membershipId}>
|
||||
{person.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Type" htmlFor="req-type">
|
||||
<select
|
||||
id="req-type"
|
||||
name="absenceTypeId"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{absenceTypes.map((type) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Premier jour" htmlFor="req-start">
|
||||
<input
|
||||
id="req-start"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Dernier jour d’absence" htmlFor="req-end">
|
||||
<input
|
||||
id="req-end"
|
||||
name="endDate"
|
||||
type="date"
|
||||
required
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-ink-2">
|
||||
<input name="startHalfDay" type="checkbox" className="size-4" />
|
||||
Demi-journée au début
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-ink-2">
|
||||
<input name="endHalfDay" type="checkbox" className="size-4" />
|
||||
Demi-journée à la fin
|
||||
</label>
|
||||
|
||||
<Field label="Commentaire" htmlFor="req-comment">
|
||||
<input
|
||||
id="req-comment"
|
||||
name="comment"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
className="h-8 w-56 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" variant="primary" size="sm" disabled={pending}>
|
||||
Demander
|
||||
</Button>
|
||||
|
||||
<p className="w-full text-micro text-ink-3">
|
||||
Le dernier jour d’absence est la veille de la reprise. Les jours fériés
|
||||
compris dans la période ne sont pas décomptés — inutile de scinder la
|
||||
demande.
|
||||
</p>
|
||||
|
||||
{state.error ? (
|
||||
<p role="alert" className="w-full text-xs text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
{state.warning ? (
|
||||
<p className="w-full text-xs text-warn-soft-ink">{state.warning}</p>
|
||||
) : null}
|
||||
{state.ok && !state.warning ? (
|
||||
<p className="w-full text-xs text-ok-soft-ink">Demande enregistrée.</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AbsenceBar, type AbsenceKind } from '@/components/planning/AbsenceBar';
|
||||
import { CounterStrip } from '@/components/planning/CounterStrip';
|
||||
import { ShiftChip } from '@/components/planning/ShiftChip';
|
||||
import { computeWeekCounters } from '@/domain/counters/week';
|
||||
@@ -182,6 +183,18 @@ function Row({
|
||||
</div>
|
||||
|
||||
<div className="relative grid flex-1 grid-cols-7">
|
||||
{row.absences.map((absence, position) => (
|
||||
<AbsenceBar
|
||||
key={`${absence.startDay}-${position}`}
|
||||
kind={absenceKind(absence.colorKey)}
|
||||
label={absence.label}
|
||||
duration={`${absence.span} j`}
|
||||
startDay={absence.startDay}
|
||||
span={absence.span}
|
||||
className="top-1.5"
|
||||
/>
|
||||
))}
|
||||
|
||||
{days.map((day, index) => {
|
||||
const shifts = row.days[index] ?? [];
|
||||
return (
|
||||
@@ -191,6 +204,13 @@ function Row({
|
||||
className={cx(
|
||||
'group/cell flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
|
||||
index > 4 && 'bg-surface-2',
|
||||
// La bande d'absence flotte au-dessus : la case lui laisse la
|
||||
// place plutôt que de la faire recouvrir un créneau.
|
||||
row.absences.some(
|
||||
(absence) =>
|
||||
index >= absence.startDay &&
|
||||
index < absence.startDay + absence.span,
|
||||
) && 'pt-7',
|
||||
)}
|
||||
>
|
||||
{shifts.map((shift) => (
|
||||
@@ -218,3 +238,16 @@ function Row({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teinte de la bande d'absence.
|
||||
*
|
||||
* Reprend la clé de couleur du type d'absence configuré par le client, avec un
|
||||
* repli neutre : une teinte inconnue vaut mieux affichée en gris qu'absente.
|
||||
*/
|
||||
function absenceKind(colorKey: string): AbsenceKind {
|
||||
const known: AbsenceKind[] = ['cp', 'rtt', 'maladie', 'sans-solde', 'attente'];
|
||||
return known.includes(colorKey as AbsenceKind)
|
||||
? (colorKey as AbsenceKind)
|
||||
: 'sans-solde';
|
||||
}
|
||||
Reference in new issue
Block a user