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:
12 files changed
+1360
-227
No files matched your search
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user