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:
1 parent
527ea2e78a
commit
98b289784a
14 files changed
+811
-351
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('/');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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('/');
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user