From 98b289784a6d4466cdc0c88e2cbd151102ca147e Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 11 Aug 2026 12:23:16 +0200 Subject: [PATCH] =?UTF-8?q?S=C3=A9parer=20les=20absences=20par=20=C3=A9tat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/(app)/absences/a-traiter/page.tsx | 36 +++ src/app/(app)/absences/calendrier/page.tsx | 332 ++++++++++++++++++++ src/app/(app)/absences/expirees/page.tsx | 33 ++ src/app/(app)/absences/traitees/page.tsx | 64 ++++ src/app/(app)/conges/page.tsx | 344 +-------------------- src/app/(app)/page.tsx | 2 +- src/components/absences/AbsenceQueue.tsx | 155 ++++++++++ src/components/shell/navigation.ts | 21 +- src/server/absences/actions.ts | 8 +- src/server/absences/queries.ts | 136 ++++++++ src/server/settings/absence-types.ts | 4 +- tests/e2e/conges.spec.ts | 18 +- tests/e2e/navigation.spec.ts | 2 +- tests/unit/navigation.test.ts | 7 +- 14 files changed, 811 insertions(+), 351 deletions(-) create mode 100644 src/app/(app)/absences/a-traiter/page.tsx create mode 100644 src/app/(app)/absences/calendrier/page.tsx create mode 100644 src/app/(app)/absences/expirees/page.tsx create mode 100644 src/app/(app)/absences/traitees/page.tsx create mode 100644 src/components/absences/AbsenceQueue.tsx diff --git a/src/app/(app)/absences/a-traiter/page.tsx b/src/app/(app)/absences/a-traiter/page.tsx new file mode 100644 index 0000000..7104904 --- /dev/null +++ b/src/app/(app)/absences/a-traiter/page.tsx @@ -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 ( + + 1 ? 's' : ''} en attente, la plus ancienne en premier` + } + /> + + + +

+ 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. +

+
+ ); +} diff --git a/src/app/(app)/absences/calendrier/page.tsx b/src/app/(app)/absences/calendrier/page.tsx new file mode 100644 index 0000000..2b5cb81 --- /dev/null +++ b/src/app/(app)/absences/calendrier/page.tsx @@ -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 = { + PAID_LEAVE: 'Congés payés', + RTT: 'RTT', + COMPENSATORY_REST: 'Repos compensateur', + MODULATION: 'Modulation', + OVERTIME: 'Heures supplémentaires', +}; + +const STATUS_TONES: Record = { + 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 ( + + 1 ? 's' : ''} sur le mois`} + actions={ + <> + + ← Mois précédent + + + Mois suivant → + + + } + /> + + + +
+

+ Demandes en attente + {board.pending.length > 0 ? ` · ${board.pending.length}` : ''} +

+ + {board.pending.length === 0 ? ( +

+ Aucune demande à traiter. +

+ ) : ( +
    + {board.pending.map((request) => ( +
  • +
    +

    + {request.name} · {request.typeLabel} +

    +

    + du {frenchDate(request.startDate)} au{' '} + {frenchDate(request.endDate)} ·{' '} + {request.days} jour + {request.days > 1 ? 's' : ''} décompté + {request.days > 1 ? 's' : ''} +

    + {request.comment ? ( +

    + « {request.comment} » +

    + ) : null} +
    + + {board.canDecide ? ( + + ) : ( + + )} +
  • + ))} +
+ )} +
+ + + +
+

+ Absences du mois +

+ {board.inMonth.length === 0 ? ( +

+ Aucune absence sur cette période. +

+ ) : ( +
    + {board.inMonth.map((request) => ( +
  • + + {statusLabel(request.status)} + + {request.name} + {request.typeLabel} + + {frenchDate(request.startDate)} → {frenchDate(request.endDate)} + + + {request.days} j + + {request.status === 'ACCEPTED' ? ( + + ) : null} +
  • + ))} +
+ )} +
+ +
+

+ Compteurs +

+ {board.counters.length === 0 ? ( +

+ Aucun compteur ouvert. Un compteur se crée à la première écriture — + acceptation d’un congé ou ajustement manuel. +

+ ) : ( + + + + + + + + + + + + {board.counters.map((counter) => ( + + + + + + + + ))} + +
+ Salarié + + Compteur + + Acquis + + Pris + + Solde +
{counter.name} + {COUNTER_LABELS[counter.counterType] ?? counter.counterType} + + {counter.accrued} + + {counter.taken} + + {counter.balance} +
+ )} +

+ 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. +

+
+
+ ); +} + +/** Calendrier du mois, une colonne par jour. */ +function MonthCalendar({ + board, +}: { + board: Awaited>; +}) { + 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 ( +
+ + + + + + {board.days.map((date) => ( + + ))} + + + + {people.map((membershipId) => { + const own = accepted.filter( + (request) => request.membershipId === membershipId, + ); + const covered = new Map(); + for (const request of own) { + for (const date of datesBetween( + request.startDate, + request.endDate, + )) { + covered.set(date, request); + } + } + + return ( + + + {board.days.map((date) => { + const request = covered.get(date); + return ( + + ); + })} + + ); + })} + +
+ Calendrier des absences, {board.label} +
+ Salarié + = 6 + ? 'bg-surface-3 text-ink-2' + : 'text-ink-1', + )} + > + {Number(date.slice(8, 10))} +
+ {own[0]?.name ?? ''} + = 6 && 'bg-surface-2', + )} + title={ + request + ? `${request.typeLabel} — ${statusLabel(request.status)}` + : undefined + } + > + {request ? ( + + + {request.typeLabel} le {date} + + + ) : null} +
+
+ ); +} + +function frenchDate(isoDate: string): string { + return isoDate.split('-').reverse().join('/'); +} diff --git a/src/app/(app)/absences/expirees/page.tsx b/src/app/(app)/absences/expirees/page.tsx new file mode 100644 index 0000000..5be11ac --- /dev/null +++ b/src/app/(app)/absences/expirees/page.tsx @@ -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 ( + + + + + +

+ 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. +

+
+ ); +} diff --git a/src/app/(app)/absences/traitees/page.tsx b/src/app/(app)/absences/traitees/page.tsx new file mode 100644 index 0000000..2a2a113 --- /dev/null +++ b/src/app/(app)/absences/traitees/page.tsx @@ -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 ( + + {label} + + ); + }; + + return ( + + + + + {tab(null, 'Toutes les absences')} + {tab('ss', 'Uniquement Sécurité sociale')} + + } + /> + +

+ 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. +

+
+ ); +} diff --git a/src/app/(app)/conges/page.tsx b/src/app/(app)/conges/page.tsx index 7ea91f6..79e6015 100644 --- a/src/app/(app)/conges/page.tsx +++ b/src/app/(app)/conges/page.tsx @@ -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 = { - PAID_LEAVE: 'Congés payés', - RTT: 'RTT', - COMPENSATORY_REST: 'Repos compensateur', - MODULATION: 'Modulation', - OVERTIME: 'Heures supplémentaires', -}; - -const STATUS_TONES: Record = { - 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 ( - - 1 ? 's' : ''} sur le mois`} - actions={ - <> - - ← Mois précédent - - - Mois suivant → - - - } - /> - - - -
-

- Demandes en attente - {board.pending.length > 0 ? ` · ${board.pending.length}` : ''} -

- - {board.pending.length === 0 ? ( -

- Aucune demande à traiter. -

- ) : ( -
    - {board.pending.map((request) => ( -
  • -
    -

    - {request.name} · {request.typeLabel} -

    -

    - du {frenchDate(request.startDate)} au{' '} - {frenchDate(request.endDate)} ·{' '} - {request.days} jour - {request.days > 1 ? 's' : ''} décompté - {request.days > 1 ? 's' : ''} -

    - {request.comment ? ( -

    - « {request.comment} » -

    - ) : null} -
    - - {board.canDecide ? ( - - ) : ( - - )} -
  • - ))} -
- )} -
- - - -
-

- Absences du mois -

- {board.inMonth.length === 0 ? ( -

- Aucune absence sur cette période. -

- ) : ( -
    - {board.inMonth.map((request) => ( -
  • - - {statusLabel(request.status)} - - {request.name} - {request.typeLabel} - - {frenchDate(request.startDate)} → {frenchDate(request.endDate)} - - - {request.days} j - - {request.status === 'ACCEPTED' ? ( - - ) : null} -
  • - ))} -
- )} -
- -
-

- Compteurs -

- {board.counters.length === 0 ? ( -

- Aucun compteur ouvert. Un compteur se crée à la première écriture — - acceptation d’un congé ou ajustement manuel. -

- ) : ( - - - - - - - - - - - - {board.counters.map((counter) => ( - - - - - - - - ))} - -
- Salarié - - Compteur - - Acquis - - Pris - - Solde -
{counter.name} - {COUNTER_LABELS[counter.counterType] ?? counter.counterType} - - {counter.accrued} - - {counter.taken} - - {counter.balance} -
- )} -

- 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. -

-
-
- ); -} - -/** 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>; + 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 ( -
- - - - - - {board.days.map((date) => ( - - ))} - - - - {people.map((membershipId) => { - const own = accepted.filter( - (request) => request.membershipId === membershipId, - ); - const covered = new Map(); - for (const request of own) { - for (const date of datesBetween( - request.startDate, - request.endDate, - )) { - covered.set(date, request); - } - } - - return ( - - - {board.days.map((date) => { - const request = covered.get(date); - return ( - - ); - })} - - ); - })} - -
- Calendrier des absences, {board.label} -
- Salarié - = 6 - ? 'bg-surface-3 text-ink-2' - : 'text-ink-1', - )} - > - {Number(date.slice(8, 10))} -
- {own[0]?.name ?? ''} - = 6 && 'bg-surface-2', - )} - title={ - request - ? `${request.typeLabel} — ${statusLabel(request.status)}` - : undefined - } - > - {request ? ( - - - {request.typeLabel} le {date} - - - ) : null} -
-
+ const { mois } = await searchParams; + permanentRedirect( + mois ? `/absences/calendrier?mois=${mois}` : '/absences/calendrier', ); } - -function frenchDate(isoDate: string): string { - return isoDate.split('-').reverse().join('/'); -} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index e3597b8..ace6472 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -276,7 +276,7 @@ function EmployeeHome() {
  • diff --git a/src/components/absences/AbsenceQueue.tsx b/src/components/absences/AbsenceQueue.tsx new file mode 100644 index 0000000..b83833a --- /dev/null +++ b/src/components/absences/AbsenceQueue.tsx @@ -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 = { + 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 ( + + 0 ? 'accent' : 'neutral'}> + {result.rows.length} ligne{result.rows.length > 1 ? 's' : ''} + + } + action={ + + } + /> + + {filterBar ? ( +
    {filterBar}
    + ) : null} + + {result.rows.length === 0 ? ( + + ) : ( +
      + {result.rows.map((row) => ( +
    • +
      +

      {row.name}

      +

      + {row.typeLabel} · {period(row)} +

      + {row.comment ? ( +

      « {row.comment} »

      + ) : null} + {row.decisionComment ? ( +

      + Décision : {row.decisionComment} +

      + ) : null} +
      + + + {row.days} j + + + {statusLabel(row.status)} + + + {result.queue === 'pending' && result.canDecide ? ( + + ) : null} + + {result.queue === 'treated' && row.status === 'ACCEPTED' ? ( + + ) : null} + + + Fiche + +
    • + ))} +
    + )} +
    + ); +} diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index 3412651..c8c5ace 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -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' }, ], }, diff --git a/src/server/absences/actions.ts b/src/server/absences/actions.ts index 7e14a2b..b8fdc52 100644 --- a/src/server/absences/actions.ts +++ b/src/server/absences/actions.ts @@ -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 }; } diff --git a/src/server/absences/queries.ts b/src/server/absences/queries.ts index 7e46a12..0e49239 100644 --- a/src/server/absences/queries.ts +++ b/src/server/absences/queries.ts @@ -322,6 +322,142 @@ export async function getAbsenceBoard(month: Month): Promise { }); } +/** + * 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; +} + +const QUEUE_STATUSES: Record< + AbsenceQueue, + Array +> = { + pending: ['PENDING'], + treated: ['ACCEPTED', 'DECLINED', 'CANCELLED'], + expired: ['EXPIRED'], +}; + +export async function getAbsenceQueue( + queue: AbsenceQueue, + filters: QueueFilters = {}, +): Promise { + 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[], diff --git a/src/server/settings/absence-types.ts b/src/server/settings/absence-types.ts index e7833cb..9ff2397 100644 --- a/src/server/settings/absence-types.ts +++ b/src/server/settings/absence-types.ts @@ -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'); } diff --git a/tests/e2e/conges.spec.ts b/tests/e2e/conges.spec.ts index cd1b0de..4149fd2 100644 --- a/tests/e2e/conges.spec.ts +++ b/tests/e2e/conges.spec.ts @@ -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(); diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index 1b477ad..166cdf8 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -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(); diff --git a/tests/unit/navigation.test.ts b/tests/unit/navigation.test.ts index 8511e8b..cbc64d3 100644 --- a/tests/unit/navigation.test.ts +++ b/tests/unit/navigation.test.ts @@ -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', () => {