WP-09 : tableau de bord RH, et disparition des données de démonstration

`src/lib/demo` n'existe plus. Aucun écran de PlanFlow ne lit désormais autre
chose que la base.

Un indicateur doit être explicable
Chaque tuile est un lien vers ses lignes sources : profils incomplets, fins de
période d'essai, titres de séjour, entrées, sorties, avenants, journal des
absences. Un chiffre qu'on ne peut pas ouvrir ne se corrige pas — il se
conteste. Et chaque ligne mène à la fiche du salarié.

Les manques sont **nommés**, pas comptés : « 6 profils incomplets » n'aide
personne à agir, « il manque l'IBAN de trois salariés » se règle en un message.
Le NIR et l'IBAN sont contrôlés par la présence de leur colonne chiffrée, jamais
déchiffrés — savoir qu'une valeur existe n'exige pas de la lire.

Des chiffres qui refusent de mentir
- La rotation moyenne entrées et sorties : compter seulement les départs
  sous-estime la rotation d'une équipe qui recrute autant qu'elle perd.
- Sur un effectif nul, elle rend `—` et non « 0 % ». Zéro pour cent de rotation
  sur un établissement vide est une affirmation fausse, pas une absence de
  mouvement.
- L'absentéisme se rapporte aux jours **théoriquement travaillés**, pas aux
  jours calendaires : rapporter à 30 jours ferait passer un problème réel pour
  du bruit.
- Un taux horaire absent vaut zéro dans le coût, jamais une estimation :
  afficher un coût inventé serait pire qu'un coût partiel.

Les échéances remontent avant de tomber
Périodes d'essai à 45 jours, titres de séjour à 90. Les échéances **dépassées**
sont conservées et placées en tête : une période d'essai qu'on a laissé filer
est plus urgente qu'une échéance à venir, et la masquer parce qu'elle est passée
est précisément ce qui la rend coûteuse.

Périmètre et confidentialité
Le filtrage par établissement s'applique **avant** l'agrégation : les mouvements
d'un autre établissement ne transparaissent pas, même fondus dans un total. Le
journal des absences ne porte jamais le motif médical — un tableau de bord n'en
a aucun besoin.

Un salarié, qui n'a pas accès à l'annuaire, reçoit un accueil adapté plutôt
qu'une erreur d'autorisation ou un tableau de bord vide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 12:40:19 +00:00
1 parent 19f082b7ef
commit 10a9c923e2
12 files changed
+1360 -227

No files matched your search

+4 -1
View File
@@ -150,7 +150,10 @@ export default async function CongesPage({ searchParams }: PageProps) {
)}
</section>
<section className="rounded-3 border border-line-1 bg-surface">
<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>
+263 -97
View File
@@ -1,127 +1,293 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader } from '@/components/ui/Card';
import { ENTRIES, TILES, TODOS } from '@/lib/demo/apercu';
import { Badge, type Tone } from '@/components/ui/Badge';
import { can } from '@/domain/access/authorize';
import { formatMinutes } from '@/domain/counters/week';
import { monthOf, parseMonthParam } from '@/domain/planning/month';
import { cx } from '@/lib/cx';
import { requireSession } from '@/server/context';
import { getDashboard, type ListKey } from '@/server/hr/queries';
export const metadata = { title: 'Aperçu RH · PlanFlow' };
const TILE_SWATCH: Record<string, string> = {
warn: 'bg-warn-soft border-warn',
info: 'bg-info-soft border-info',
accent: 'bg-accent-soft border-accent',
danger: 'bg-danger-soft border-danger',
interface PageProps {
searchParams: Promise<{
mois?: string;
etablissement?: string;
liste?: string;
}>;
}
const LIST_LABELS: Record<ListKey, string> = {
entrees: 'Entrées du mois',
sorties: 'Sorties du mois',
essais: 'Fins de période d’essai',
profils: 'Profils incomplets',
titres: 'Titres de séjour',
absences: 'Journal des absences',
contrats: 'Modifications de contrat',
};
export default function ApercuPage() {
const LIST_KEYS = Object.keys(LIST_LABELS) as ListKey[];
export default async function ApercuPage({ searchParams }: PageProps) {
const params = await searchParams;
const session = await requireSession();
// Un salarié n'a pas accès à l'annuaire : lui servir une erreur
// d'autorisation sur la page d'accueil serait absurde. Il voit son propre
// point d'entrée.
if (!can(session.actor, 'members.view')) {
return <EmployeeHome />;
}
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
const board = await getDashboard(month, params.etablissement);
const selected: ListKey = LIST_KEYS.includes(params.liste as ListKey)
? (params.liste as ListKey)
: 'profils';
const href = (
next: Partial<{ mois: string; etablissement: string; liste: string }>,
) => {
const query = new URLSearchParams({
mois: next.mois ?? board.monthParam,
liste: next.liste ?? selected,
});
const location = next.etablissement ?? board.location?.id;
if (location) query.set('etablissement', location);
return `/?${query.toString()}`;
};
return (
<PageBody>
<PageHeader
title="Aperçu RH"
subtitle="Vos tâches en cours sur Nantes Atlantis · lundi 10 août 2026"
subtitle={`${board.location?.name ?? 'Tous établissements'} · ${board.label}`}
actions={
<>
<Button>Exporter</Button>
<Button variant="primary">Nouvelle embauche</Button>
<Link
href={href({ mois: 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({ mois: 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>
</>
}
/>
<div className="grid gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(232px,1fr))]">
{TILES.map((tile) => (
<button
key={tile.label}
type="button"
className="flex cursor-pointer items-center gap-3 rounded-3 border border-line-1 bg-surface px-4 py-3.5 text-left transition-[border-color,box-shadow] duration-[var(--d-1)] ease-organic hover:border-line-3 hover:shadow-e1"
<div className="flex flex-wrap items-center gap-2">
{board.locations.map((location) => (
<Link
key={location.id}
href={href({ etablissement: location.id })}
aria-current={
location.id === board.location?.id ? 'page' : undefined
}
className={
location.id === board.location?.id
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
}
>
<span
aria-hidden
className={`size-8 flex-none rounded-2 border ${TILE_SWATCH[tile.tone] ?? ''}`}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs text-ink-2">
{tile.label}
</span>
<span className="tnum block text-xl leading-tight font-semibold">
{tile.value}
</span>
</span>
<span aria-hidden className="flex-none text-ink-3">
›
</span>
</button>
{location.name}
</Link>
))}
</div>
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(380px,1fr))]">
<Card>
<CardHeader
title="Entrées à venir"
badge={<Badge tone="accent">4</Badge>}
action={
<Button size="sm" variant="ghost">
Voir tout
</Button>
}
/>
<ul>
{ENTRIES.map((entry) => (
<li
key={entry.name}
className="flex items-center gap-3 border-b border-line-1 px-4 py-2.5 last:border-b-0 hover:bg-surface-2"
>
<span
aria-hidden
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
>
{entry.initials}
{/* Chaque tuile est un lien : un indicateur qu'on ne peut pas ouvrir ne
se corrige pas, il se conteste. */}
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{board.tiles.map((tile) => (
<li key={tile.key}>
<Link
href={href({ liste: tile.key })}
aria-current={tile.key === selected ? 'true' : undefined}
className={cx(
'flex h-full flex-col gap-1 rounded-3 border bg-surface p-4 transition-colors hover:bg-surface-2',
tile.key === selected ? 'border-accent' : 'border-line-1',
)}
>
<span className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-ink-1">
{tile.label}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{entry.name}
</span>
<span className="block truncate text-micro text-ink-3">
{entry.job}
</span>
</span>
<span className="tnum flex-none text-xs text-ink-2">
{entry.date}
</span>
<Badge tone={entry.tone}>{entry.state}</Badge>
</li>
))}
</ul>
</Card>
<Badge tone={tile.tone as Tone}>{tile.value}</Badge>
</span>
<span className="text-micro text-ink-3">{tile.hint}</span>
</Link>
</li>
))}
</ul>
<Card>
<CardHeader title="À traiter cette semaine" />
<ul>
{TODOS.map((todo) => (
<li
key={todo.title}
className="flex items-start gap-3 border-b border-line-1 px-4 py-3 last:border-b-0 hover:bg-surface-2"
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Indicator
label="Effectif en fin de mois"
value={String(board.indicators.closingHeadcount)}
hint={`moyenne ${board.indicators.averageHeadcount}`}
/>
<Indicator
label="Rotation"
value={
board.indicators.turnoverRate === null
? '—'
: `${board.indicators.turnoverRate} %`
}
hint={
board.indicators.turnoverRate === null
? 'Effectif nul sur la période'
: 'Moyenne des entrées et sorties'
}
/>
<Indicator
label="Absentéisme"
value={
board.indicators.absenteeismRate === null
? '—'
: `${board.indicators.absenteeismRate} %`
}
hint={`${board.indicators.absenceDays} j dont ${board.indicators.sickDays} j d’arrêt`}
/>
<Indicator
label="Heures planifiées"
value={formatMinutes(board.indicators.plannedMinutes)}
hint={
board.canSeeCost
? board.indicators.labourCost === null
? 'Coût indisponible'
: `coût chargé ${board.indicators.labourCost.toLocaleString('fr-FR')} €`
: 'Coût masqué'
}
/>
</section>
<section className="rounded-3 border border-line-1 bg-surface">
<div className="flex flex-wrap items-center gap-2 border-b border-line-1 px-4 py-2">
<h2 className="text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
{LIST_LABELS[selected]}
</h2>
<span className="text-xs text-ink-3">
{board.lists[selected].length} ligne
{board.lists[selected].length > 1 ? 's' : ''}
</span>
<div className="ml-auto flex flex-wrap gap-1">
{LIST_KEYS.map((key) => (
<Link
key={key}
href={href({ liste: key })}
className={cx(
'rounded-2 px-2 py-0.5 text-micro',
key === selected
? 'bg-accent-soft text-accent-soft-ink'
: 'text-ink-3 hover:bg-surface-2',
)}
>
<span
aria-hidden
className="mt-1.5 size-2 flex-none rounded-full"
style={{ background: todo.dot }}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{todo.title}
{LIST_LABELS[key]}
</Link>
))}
</div>
</div>
{board.lists[selected].length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Rien à signaler sur cette période.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.lists[selected].map((row) => (
<li
key={row.id}
className="flex flex-wrap items-center gap-3 px-4 py-2.5"
>
<Link
href={`/equipe/${row.membershipId}`}
className="text-sm font-medium text-ink-1 underline-offset-2 hover:underline"
>
{row.name}
</Link>
<span className="text-sm text-ink-2">{row.detail}</span>
{row.date ? (
<span className="tnum ml-auto text-xs text-ink-3">
{row.date.split('-').reverse().join('/')}
</span>
<span className="mt-0.5 block text-xs leading-[var(--lh-prose)] text-ink-2">
{todo.detail}
</span>
</span>
<span className="tnum flex-none whitespace-nowrap text-micro text-ink-3">
{todo.when}
</span>
) : null}
</li>
))}
</ul>
</Card>
</div>
)}
</section>
</PageBody>
);
}
function Indicator({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) {
return (
<div className="rounded-3 border border-line-1 bg-surface p-4">
<p className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
{label}
</p>
<p className="tnum mt-1 text-2xl font-semibold text-ink-1">{value}</p>
<p className="mt-0.5 text-micro text-ink-3">{hint}</p>
</div>
);
}
/**
* Accueil d'un salarié.
*
* Il n'a ni annuaire ni indicateurs : lui servir une erreur d'autorisation sur
* la page d'accueil serait absurde, et lui montrer un tableau de bord vide le
* serait tout autant.
*/
function EmployeeHome() {
return (
<PageBody>
<PageHeader
title="Aperçu"
subtitle="Vos plannings, vos absences et vos compteurs."
/>
<ul className="grid gap-3 sm:grid-cols-2">
<li>
<Link
href="/planning/semaine"
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">Mon planning</span>
<span className="text-micro text-ink-3">
Les semaines publiées de votre équipe
</span>
</Link>
</li>
<li>
<Link
href="/conges"
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">
Mes absences et compteurs
</span>
<span className="text-micro text-ink-3">
Demander un congé, suivre son solde
</span>
</Link>
</li>
</ul>
</PageBody>
);
}