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', () => {