Séparer les absences par état

Le calendrier portait tout : les demandes en attente, les décisions rendues et
celles qui avaient expiré, distinguées par un simple badge. Une file d'attente
se lit mal dans un calendrier — elle se lit par ancienneté, et le calendrier
range par date d'absence.

Quatre routes désormais : le calendrier, la file à traiter, les traitées et les
expirées. Le calendrier a déménagé sous /absences plutôt que d'être dupliqué, et
/conges redirige en conservant le mois : un lien partagé dans un message
continue d'ouvrir la bonne page.

Les trois files partagent une requête et un rendu. C'est délibéré : la règle qui
masque le motif d'une absence Sécurité sociale vaut pour les trois, et trois
copies de cette règle garantiraient qu'une finisse par diverger. Une donnée de
santé qui ne fuit que sur l'écran « traitées » reste une fuite.

La file à traiter classe par ancienneté de demande, les files closes par date
d'absence décroissante : on cherche d'un côté ce qui attend depuis trop
longtemps, de l'autre ce qui vient d'être décidé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 12:23:16 +02:00
1 parent 527ea2e78a
commit 98b289784a
14 files changed
+811 -351

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { getAbsenceQueue } from '@/server/absences/queries';
export const metadata = { title: 'Demandes à traiter · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ATraiterPage() {
const result = await getAbsenceQueue('pending');
return (
<PageBody>
<PageHeader
title="Demandes à traiter"
subtitle={
result.rows.length === 0
? 'Aucune demande en attente'
: `${result.rows.length} demande${result.rows.length > 1 ? 's' : ''} en attente, la plus ancienne en premier`
}
/>
<AbsenceQueueView
result={result}
title="File d’attente"
description="Aucune demande en attente de décision."
/>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Les demandes sont classées par ancienneté : celle qui attend depuis le
plus longtemps arrive en tête. Accepter écrit au registre des compteurs ;
annuler ensuite contre-passe l’écriture plutôt que de l’effacer, et le
solde revient au même chiffre.
</p>
</PageBody>
);
}
+332
View File
@@ -0,0 +1,332 @@
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, 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 { getAbsenceBoard, statusLabel } from '@/server/absences/queries';
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
interface PageProps {
searchParams: Promise<{ mois?: string }>;
}
const COUNTER_LABELS: Record<string, string> = {
PAID_LEAVE: 'Congés payés',
RTT: 'RTT',
COMPENSATORY_REST: 'Repos compensateur',
MODULATION: 'Modulation',
OVERTIME: 'Heures supplémentaires',
};
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) => `/absences/calendrier?mois=${mois}`;
return (
<PageBody>
<PageHeader
title="Calendrier des absences"
subtitle={`${board.label} · ${board.inMonth.length} absence${board.inMonth.length > 1 ? 's' : ''} sur le mois`}
actions={
<>
<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>
</>
}
/>
<RequestForm
absenceTypes={board.absenceTypes}
people={board.people}
ownMembershipId={board.ownMembershipId}
/>
<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="tnum ml-auto text-xs text-ink-2">
{request.days} j
</span>
{request.status === 'ACCEPTED' ? (
<CancelButton timeOffId={request.id} />
) : null}
</li>
))}
</ul>
)}
</section>
<section
id="compteurs"
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('/');
}
+33
View File
@@ -0,0 +1,33 @@
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { getAbsenceQueue } from '@/server/absences/queries';
export const metadata = { title: 'Absences expirées · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ExpireesPage() {
const result = await getAbsenceQueue('expired');
return (
<PageBody>
<PageHeader
title="Absences expirées"
subtitle="Demandes dont la date est passée sans décision"
/>
<AbsenceQueueView
result={result}
title="Demandes expirées"
description="Aucune demande n’a expiré."
/>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Une demande expirée n’a jamais été décidée : elle n’a donc rien écrit au
registre des compteurs, et le solde du salarié n’en porte aucune trace.
Elle reste visible parce qu’une absence non traitée à temps est un fait
opposable — au salarié qui l’a demandée comme à l’employeur qui ne l’a
pas tranchée.
</p>
</PageBody>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Link from 'next/link';
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { getAbsenceQueue } from '@/server/absences/queries';
export const metadata = { title: 'Absences traitées · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ nature?: string }>;
}
export default async function TraiteesPage({ searchParams }: PageProps) {
const { nature } = await searchParams;
// Filtre relevé à l'audit des menus : « Toutes les absences » ou
// « Uniquement les absences Sécurité sociale ». Deux valeurs, pas trois.
const socialSecurityOnly = nature === 'ss';
const result = await getAbsenceQueue('treated', { socialSecurityOnly });
const tab = (value: string | null, label: string) => {
const active = (value === 'ss') === socialSecurityOnly;
return (
<Link
href={value ? `/absences/traitees?nature=${value}` : '/absences/traitees'}
aria-current={active ? 'page' : undefined}
className={
active
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
}
>
{label}
</Link>
);
};
return (
<PageBody>
<PageHeader
title="Absences traitées"
subtitle="Décisions rendues : acceptées, refusées et annulées"
/>
<AbsenceQueueView
result={result}
title="Décisions rendues"
description="Aucune absence traitée pour ce filtre."
filterBar={
<div className="flex flex-wrap items-center gap-1">
{tab(null, 'Toutes les absences')}
{tab('ss', 'Uniquement Sécurité sociale')}
</div>
}
/>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Le motif d’une absence Sécurité sociale est une donnée de santé : il
n’apparaît qu’aux personnes habilitées à lire les pièces du dossier. Pour
les autres, la ligne dit « Absence » et rien de plus.
</p>
</PageBody>
);
}
+17 -327
View File
@@ -1,332 +1,22 @@
import Link from 'next/link';
import { permanentRedirect } from 'next/navigation';
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
import { RequestForm } from '@/components/absences/RequestForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
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 { getAbsenceBoard, statusLabel } from '@/server/absences/queries';
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
interface PageProps {
searchParams: Promise<{ mois?: string }>;
}
const COUNTER_LABELS: Record<string, string> = {
PAID_LEAVE: 'Congés payés',
RTT: 'RTT',
COMPENSATORY_REST: 'Repos compensateur',
MODULATION: 'Modulation',
OVERTIME: 'Heures supplémentaires',
};
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={`${board.label} · ${board.inMonth.length} absence${board.inMonth.length > 1 ? 's' : ''} sur le mois`}
actions={
<>
<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>
</>
}
/>
<RequestForm
absenceTypes={board.absenceTypes}
people={board.people}
ownMembershipId={board.ownMembershipId}
/>
<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="tnum ml-auto text-xs text-ink-2">
{request.days} j
</span>
{request.status === 'ACCEPTED' ? (
<CancelButton timeOffId={request.id} />
) : null}
</li>
))}
</ul>
)}
</section>
<section
id="compteurs"
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,
/**
* Ancienne adresse du calendrier des absences.
*
* Les absences vivent désormais sous `/absences`, avec une route par état
* (§9). Le calendrier a déménagé plutôt que d'être dupliqué : deux écrans
* lisant le même modèle finissent toujours par diverger d'une correction.
*
* La redirection est **permanente** et conserve le mois demandé : un lien de
* planning partagé dans un message continue d'ouvrir la bonne page.
*/
export default async function CongesRedirect({
searchParams,
}: {
board: Awaited<ReturnType<typeof getAbsenceBoard>>;
searchParams: Promise<{ mois?: string }>;
}) {
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>
const { mois } = await searchParams;
permanentRedirect(
mois ? `/absences/calendrier?mois=${mois}` : '/absences/calendrier',
);
}
function frenchDate(isoDate: string): string {
return isoDate.split('-').reverse().join('/');
}
+1 -1
View File
@@ -276,7 +276,7 @@ function EmployeeHome() {
</li>
<li>
<Link
href="/conges"
href="/absences/calendrier"
className="flex h-full flex-col gap-1 rounded-3 border border-line-1 bg-surface p-4 hover:bg-surface-2"
>
<span className="text-sm font-medium text-ink-1">
+155
View File
@@ -0,0 +1,155 @@
import Link from 'next/link';
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
import { Badge, type Tone } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
statusLabel,
type AbsenceQueue as QueueKey,
type AbsenceQueueResult,
} from '@/server/absences/queries';
/**
* Files d'absences par état — PLAN.md §9.
*
* Les trois files partagent ce rendu. Une seule différence les sépare : la file
* à traiter porte les boutons de décision, les autres n'en portent pas — une
* absence déjà décidée ne se re-décide pas depuis une liste, elle s'annule
* depuis la fiche, avec sa contre-passe au registre.
*/
const STATUS_TONES: Record<string, Tone> = {
PENDING: 'warn',
ACCEPTED: 'ok',
DECLINED: 'danger',
CANCELLED: 'neutral',
EXPIRED: 'neutral',
};
export const QUEUE_TABS: Array<{ key: QueueKey; href: string; label: string }> = [
{ key: 'pending', href: '/absences/a-traiter', label: 'À traiter' },
{ key: 'treated', href: '/absences/traitees', label: 'Traitées' },
{ key: 'expired', href: '/absences/expirees', label: 'Expirées' },
];
const dateFormat = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
function formatDay(iso: string): string {
return dateFormat.format(new Date(`${iso}T00:00:00Z`));
}
/**
* Période affichée d'une absence.
*
* `endDate` porte le **dernier jour d'absence**, pas la date de reprise. La
* confusion est la plus fréquente du domaine et se paie en un jour de congé de
* trop ou de trop peu ; l'écran l'écrit donc explicitement.
*/
function period(row: AbsenceQueueResult['rows'][number]): string {
const start = formatDay(row.startDate);
if (row.startDate === row.endDate) {
return row.startHalfDay || row.endHalfDay ? `${start} (demi-journée)` : start;
}
return `du ${start} au ${formatDay(row.endDate)} inclus`;
}
export function AbsenceQueueView({
result,
title,
description,
filterBar,
}: {
result: AbsenceQueueResult;
title: string;
description: string;
filterBar?: React.ReactNode;
}) {
return (
<Card>
<CardHeader
title={title}
badge={
<Badge tone={result.rows.length > 0 ? 'accent' : 'neutral'}>
{result.rows.length} ligne{result.rows.length > 1 ? 's' : ''}
</Badge>
}
action={
<nav aria-label="Files d’absences" className="flex gap-1">
{QUEUE_TABS.map((tab) => (
<Link
key={tab.key}
href={tab.href}
aria-current={tab.key === result.queue ? 'page' : undefined}
className={
tab.key === result.queue
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
}
>
{tab.label}
<span className="ml-1.5 tnum text-ink-3">
{result.counts[tab.key]}
</span>
</Link>
))}
</nav>
}
/>
{filterBar ? (
<div className="border-b border-line-1 px-4 py-2.5">{filterBar}</div>
) : null}
{result.rows.length === 0 ? (
<EmptyState title="Aucune absence" description={description} />
) : (
<ul className="divide-y divide-line-1">
{result.rows.map((row) => (
<li key={row.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="min-w-[14rem] flex-1">
<p className="text-sm font-medium">{row.name}</p>
<p className="text-xs text-ink-2">
{row.typeLabel} · {period(row)}
</p>
{row.comment ? (
<p className="mt-1 text-xs text-ink-3">« {row.comment} »</p>
) : null}
{row.decisionComment ? (
<p className="mt-1 text-xs text-ink-3">
Décision : {row.decisionComment}
</p>
) : null}
</div>
<Badge tone="neutral" className="tnum">
{row.days} j
</Badge>
<Badge tone={STATUS_TONES[row.status] ?? 'neutral'}>
{statusLabel(row.status)}
</Badge>
{result.queue === 'pending' && result.canDecide ? (
<DecisionButtons timeOffId={row.id} />
) : null}
{result.queue === 'treated' && row.status === 'ACCEPTED' ? (
<CancelButton timeOffId={row.id} />
) : null}
<Link
href={`/equipe/${row.membershipId}/absences`}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
Fiche
</Link>
</li>
))}
</ul>
)}
</Card>
);
}
+16 -5
View File
@@ -32,9 +32,14 @@ export const NAVIGATION: NavSection[] = [
{ id: 'entrees', label: 'Entrées et sorties', href: '/?liste=entrees' },
{ id: 'profils', label: 'Profils incomplets', href: '/?liste=profils' },
{ id: 'titres', label: 'Titres de séjour', href: '/?liste=titres' },
// Ancre plutôt que route : `/conges` appartient à la section Congés, et
// deux entrées allumées pour un même écran rendent la navigation muette.
{ id: 'soldes-cp', label: 'Compteurs de congés payés', href: '/conges#compteurs' },
// Ancre plutôt que route : le calendrier appartient à la section Congés,
// et deux entrées allumées pour un même écran rendent la navigation
// muette.
{
id: 'soldes-cp',
label: 'Compteurs de congés payés',
href: '/absences/calendrier#compteurs',
},
],
},
{
@@ -61,8 +66,14 @@ export const NAVIGATION: NavSection[] = [
id: 'conges',
label: 'Congés',
items: [
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente' },
{
id: 'calendrier',
label: 'Calendrier des absences',
href: '/absences/calendrier',
},
{ id: 'attente', label: 'Demandes à traiter', href: '/absences/a-traiter' },
{ id: 'traitees', label: 'Traitées', href: '/absences/traitees' },
{ id: 'expirees', label: 'Expirées', href: '/absences/expirees' },
{ id: 'politiques', label: 'Politiques de congés' },
],
},
+4 -4
View File
@@ -229,7 +229,7 @@ export async function requestTimeOffAction(
return toState(error, "Vous n'avez pas le droit de poser cette absence.");
}
revalidatePath('/conges');
revalidatePath('/absences/calendrier');
return warning ? { ok: true, warning } : { ok: true };
}
@@ -328,7 +328,7 @@ export async function decideTimeOffAction(
return toState(error, "Vous n'avez pas le droit de décider de cette demande.");
}
revalidatePath('/conges');
revalidatePath('/absences/calendrier');
return { ok: true };
}
@@ -384,7 +384,7 @@ export async function cancelTimeOffAction(
return toState(error, "Vous n'avez pas le droit d'annuler cette absence.");
}
revalidatePath('/conges');
revalidatePath('/absences/calendrier');
return { ok: true };
}
@@ -462,7 +462,7 @@ export async function adjustCounterAction(
return toState(error, "Vous n'avez pas le droit d'ajuster un compteur.");
}
revalidatePath('/conges');
revalidatePath('/absences/calendrier');
return { ok: true };
}
+136
View File
@@ -322,6 +322,142 @@ export async function getAbsenceBoard(month: Month): Promise<AbsenceBoard> {
});
}
/**
* File d'absences filtrée par état — PLAN.md §9, écrans `/absences/*`.
*
* Les trois files partagent une requête plutôt qu'un écran chacune : la règle
* de masquage du motif médical vaut pour les trois, et la dupliquer trois fois
* garantirait qu'une des copies finisse par diverger. Une donnée de santé qui
* ne fuit que sur l'écran « traitées » reste une fuite.
*/
export type AbsenceQueue = 'pending' | 'treated' | 'expired';
export interface QueueFilters {
/** Restreint à un type d'absence. */
typeId?: string;
/** `true` : uniquement les absences Sécurité sociale (filtre relevé à l'audit). */
socialSecurityOnly?: boolean;
}
export interface AbsenceQueueResult {
queue: AbsenceQueue;
rows: AbsenceRequest[];
absenceTypes: Array<{ id: string; code: string; name: string }>;
canDecide: boolean;
ownMembershipId: string;
/** Décomptes des trois files, pour les onglets. */
counts: Record<AbsenceQueue, number>;
}
const QUEUE_STATUSES: Record<
AbsenceQueue,
Array<AbsenceRequest['status']>
> = {
pending: ['PENDING'],
treated: ['ACCEPTED', 'DECLINED', 'CANCELLED'],
expired: ['EXPIRED'],
};
export async function getAbsenceQueue(
queue: AbsenceQueue,
filters: QueueFilters = {},
): Promise<AbsenceQueueResult> {
return query('timeoff.view_own', async (db, actor) => {
const canSeeOthers = can(actor, 'timeoff.view_others');
const canDecide = can(actor, 'timeoff.decide');
const canSeeMedical = can(actor, 'members.documents.view');
const scope = canSeeOthers ? {} : { membershipId: actor.membershipId };
const rows = await db.timeOff.findMany({
where: {
...scope,
status: { in: QUEUE_STATUSES[queue] },
...(filters.typeId ? { absenceTypeId: filters.typeId } : {}),
...(filters.socialSecurityOnly
? { absenceType: { isSocialSecurity: true } }
: {}),
},
include: { absenceType: true },
// La file à traiter se lit par ancienneté — la plus vieille demande est
// celle qui coûte le plus au salarié qui attend. Les files closes se
// lisent à l'envers : on y cherche ce qui vient d'être décidé.
orderBy:
queue === 'pending'
? { requestedAt: 'asc' }
: { startDate: 'desc' },
take: 500,
});
const memberIds = [...new Set(rows.map((entry) => entry.membershipId))];
const members = await db.membership.findMany({
where: { 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 counts = await db.timeOff.groupBy({
by: ['status'],
where: scope,
_count: { _all: true },
});
const byStatus = new Map(
counts.map((row) => [row.status, row._count._all] as const),
);
const total = (queueKey: AbsenceQueue) =>
QUEUE_STATUSES[queueKey].reduce(
(sum, status) => sum + (byStatus.get(status) ?? 0),
0,
);
const absenceTypes = await db.absenceType.findMany({
where: { archivedAt: null },
select: { id: true, code: true, name: true },
orderBy: { code: 'asc' },
});
return {
queue,
rows: rows.map((entry) => ({
id: entry.id,
membershipId: entry.membershipId,
name: nameById.get(entry.membershipId) ?? 'Salarié',
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,
})),
absenceTypes,
canDecide,
ownMembershipId: actor.membershipId,
counts: {
pending: total('pending'),
treated: total('treated'),
expired: total('expired'),
},
};
});
}
/** Absences acceptées d'une période, pour la grille de planning. */
export async function acceptedAbsencesBetween(
membershipIds: string[],
+2 -2
View File
@@ -231,7 +231,7 @@ export async function updateAbsenceTypeAction(
}
revalidatePath('/reglages/types-absence');
revalidatePath('/conges');
revalidatePath('/absences/calendrier');
return { ok: true };
}
@@ -262,5 +262,5 @@ export async function archiveAbsenceTypeAction(
});
revalidatePath('/reglages/types-absence');
revalidatePath('/conges');
revalidatePath('/absences/calendrier');
}
+9 -9
View File
@@ -55,7 +55,7 @@ function nextWeekday(weekday: number): string {
* attente » et « acceptée » bloquent.
*/
async function release(page: Page, marker: string, month?: string) {
await page.goto(month ? `/conges?mois=${month}` : '/conges');
await page.goto(month ? `/absences/calendrier?mois=${month}` : '/absences/calendrier');
const pending = page
.locator('section')
@@ -117,7 +117,7 @@ async function request(
test('le formulaire nomme le dernier jour d’absence, pas la date de fin', async ({
page,
}) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
// Le libellé exact coûte moins cher qu'une régularisation de solde.
await expect(
page.getByText('Dernier jour d’absence', { exact: true }),
@@ -128,7 +128,7 @@ test('le formulaire nomme le dernier jour d’absence, pas la date de fin', asyn
});
test('une demande apparaît dans la file, puis se décide', async ({ page }) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
const from = isoDate(0);
const to = isoDate(4);
@@ -160,7 +160,7 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
// La ligne est visée par **ses** dates, pas par « la première de ce
// salarié » : les passages précédents en laissent d'autres dans le même
// mois, et certaines n'offrent plus de bouton d'annulation.
await page.goto(`/conges?mois=${from.slice(0, 7)}`);
await page.goto(`/absences/calendrier?mois=${from.slice(0, 7)}`);
const accepted = page
.locator('section')
.filter({ hasText: 'Absences du mois' })
@@ -173,7 +173,7 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
});
test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
await release(page, 'Chevauchement ');
@@ -208,7 +208,7 @@ test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
});
test('une période sans jour décomptable est refusée', async ({ page }) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
// Un dimanche seul : jamais ouvrable, donc rien à décompter.
const sunday = nextWeekday(0);
@@ -222,7 +222,7 @@ test('une période sans jour décomptable est refusée', async ({ page }) => {
});
test('un jour férié dans la période n’est pas décompté', async ({ page }) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
// 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
@@ -263,7 +263,7 @@ test('un jour férié dans la période n’est pas décompté', async ({ page })
test('une date de fin antérieure au début rappelle la règle', async ({
page,
}) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
const form = await request(page, {
from: isoDate(22),
@@ -277,7 +277,7 @@ test('une date de fin antérieure au début rappelle la règle', async ({
});
test('le solde revient à son niveau après un aller-retour', async ({ page }) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
const counters = page.locator('section').filter({ hasText: 'Compteurs' });
await expect(counters).toBeVisible();
+1 -1
View File
@@ -52,7 +52,7 @@ test('une entrée de navigation non construite mène à un écran explicite', as
page,
}) => {
// La barre latérale n'affiche que la section courante : on l'ouvre d'abord.
await page.goto('/conges');
await page.goto('/absences/calendrier');
// « Politiques de congés » fait partie du périmètre visé mais n'est pas
// encore construit : l'entrée reste, et mène à un écran qui le dit.
await page.getByRole('link', { name: 'Politiques de congés' }).click();
+5 -2
View File
@@ -21,7 +21,9 @@ describe('matches', () => {
});
it('écarte les liens d’ancre', () => {
expect(matches('/conges#attente', '/conges')).toBe(false);
expect(
matches('/absences/calendrier#compteurs', '/absences/calendrier'),
).toBe(false);
});
});
@@ -61,7 +63,8 @@ describe('sectionForPath', () => {
it('trouve la section portant la route courante', () => {
expect(sectionForPath('/planning/semaine').id).toBe('plannings');
expect(sectionForPath('/equipe/camille-ferrand').id).toBe('equipe');
expect(sectionForPath('/conges').id).toBe('conges');
expect(sectionForPath('/absences/calendrier').id).toBe('conges');
expect(sectionForPath('/absences/a-traiter').id).toBe('conges');
});
it('retombe sur la première section pour une route inconnue', () => {