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
@@ -12,9 +12,37 @@ La spécification de construction est [`PLAN.md`](PLAN.md). Elle est normative :
|
|||||||
|
|
||||||
## État
|
## État
|
||||||
|
|
||||||
**WP-00 — socle** et **implémentation du design** : six écrans sur données de démonstration.
|
Neuf lots livrés. **Aucun écran ne lit plus de données de démonstration** : le
|
||||||
|
répertoire `src/lib/demo` a disparu.
|
||||||
|
|
||||||
**WP-01 — tenancy, identité, autorisation** : en cours. Modèle de données, RLS, journal d'audit append-only, catalogue de 70 capacités, cinq rôles, sessions en base. Les écrans lisent encore `src/lib/demo`.
|
| Lot | Contenu |
|
||||||
|
|---|---|
|
||||||
|
| WP-00 | Socle : Next.js, PostgreSQL, CSP restrictive, CI, sauvegardes |
|
||||||
|
| WP-01 | Tenancy, identité, autorisation : RLS, audit append-only, 70 capacités, cinq rôles |
|
||||||
|
| WP-02 | Référentiels et registre de paramétrage juridique |
|
||||||
|
| WP-03 | Dossiers salariés, contrats et avenants, forfait jours |
|
||||||
|
| WP-04 | Planning : quatre vues, publication par équipe, impression |
|
||||||
|
| WP-05 | Moteur de règles de convention, effectif-daté |
|
||||||
|
| WP-06 | Absences, registre de compteurs, calendrier |
|
||||||
|
| WP-07 | Heures prévu/réalisé/payé, périodes de paie verrouillables |
|
||||||
|
| WP-08 | Export Silae, format relevé sur un export réel du dossier |
|
||||||
|
| WP-09 | Tableau de bord RH : indicateurs explicables |
|
||||||
|
|
||||||
|
Restent WP-10 (documents) et WP-11 (communication, optionnel), ainsi que deux
|
||||||
|
points nommés à l'intérieur des lots livrés : la régularisation automatique sur
|
||||||
|
la période suivante et l'information au retour d'arrêt.
|
||||||
|
|
||||||
|
### Ce qui attend une décision du client
|
||||||
|
|
||||||
|
Ces points sont des **signaux d'arrêt** au sens de `PLAN.md` : ils ne se
|
||||||
|
devinent pas.
|
||||||
|
|
||||||
|
- Les **codes d'absence Silae** (`AB-100`, `AB-200`, `AB-300`, `AB-630`) : leur
|
||||||
|
existence est connue, leur signification non. L'export refuse de tourner tant
|
||||||
|
que la correspondance n'est pas confirmée.
|
||||||
|
- Le **régime dominical** applicable aux magasins, qui détermine si la
|
||||||
|
majoration de 100 % et le repos compensateur sont les bonnes contreparties.
|
||||||
|
- Les **règles applicables aux mineurs**, sans source primaire au dossier.
|
||||||
|
|
||||||
## Démarrer
|
## Démarrer
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,10 @@ export default async function CongesPage({ searchParams }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||||
Compteurs
|
Compteurs
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
+263
-97
@@ -1,127 +1,293 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { can } from '@/domain/access/authorize';
|
||||||
import { Card, CardHeader } from '@/components/ui/Card';
|
import { formatMinutes } from '@/domain/counters/week';
|
||||||
import { ENTRIES, TILES, TODOS } from '@/lib/demo/apercu';
|
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' };
|
export const metadata = { title: 'Aperçu RH · PlanFlow' };
|
||||||
|
|
||||||
const TILE_SWATCH: Record<string, string> = {
|
interface PageProps {
|
||||||
warn: 'bg-warn-soft border-warn',
|
searchParams: Promise<{
|
||||||
info: 'bg-info-soft border-info',
|
mois?: string;
|
||||||
accent: 'bg-accent-soft border-accent',
|
etablissement?: string;
|
||||||
danger: 'bg-danger-soft border-danger',
|
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 (
|
return (
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Aperçu RH"
|
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={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button>Exporter</Button>
|
<Link
|
||||||
<Button variant="primary">Nouvelle embauche</Button>
|
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))]">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{TILES.map((tile) => (
|
{board.locations.map((location) => (
|
||||||
<button
|
<Link
|
||||||
key={tile.label}
|
key={location.id}
|
||||||
type="button"
|
href={href({ etablissement: location.id })}
|
||||||
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"
|
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
|
{location.name}
|
||||||
aria-hidden
|
</Link>
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(380px,1fr))]">
|
{/* Chaque tuile est un lien : un indicateur qu'on ne peut pas ouvrir ne
|
||||||
<Card>
|
se corrige pas, il se conteste. */}
|
||||||
<CardHeader
|
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
title="Entrées à venir"
|
{board.tiles.map((tile) => (
|
||||||
badge={<Badge tone="accent">4</Badge>}
|
<li key={tile.key}>
|
||||||
action={
|
<Link
|
||||||
<Button size="sm" variant="ghost">
|
href={href({ liste: tile.key })}
|
||||||
Voir tout
|
aria-current={tile.key === selected ? 'true' : undefined}
|
||||||
</Button>
|
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',
|
||||||
<ul>
|
)}
|
||||||
{ENTRIES.map((entry) => (
|
>
|
||||||
<li
|
<span className="flex items-center justify-between gap-2">
|
||||||
key={entry.name}
|
<span className="text-sm font-medium text-ink-1">
|
||||||
className="flex items-center gap-3 border-b border-line-1 px-4 py-2.5 last:border-b-0 hover:bg-surface-2"
|
{tile.label}
|
||||||
>
|
|
||||||
<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}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<Badge tone={tile.tone as Tone}>{tile.value}</Badge>
|
||||||
<span className="block truncate text-sm font-medium">
|
</span>
|
||||||
{entry.name}
|
<span className="text-micro text-ink-3">{tile.hint}</span>
|
||||||
</span>
|
</Link>
|
||||||
<span className="block truncate text-micro text-ink-3">
|
</li>
|
||||||
{entry.job}
|
))}
|
||||||
</span>
|
</ul>
|
||||||
</span>
|
|
||||||
<span className="tnum flex-none text-xs text-ink-2">
|
|
||||||
{entry.date}
|
|
||||||
</span>
|
|
||||||
<Badge tone={entry.tone}>{entry.state}</Badge>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<CardHeader title="À traiter cette semaine" />
|
<Indicator
|
||||||
<ul>
|
label="Effectif en fin de mois"
|
||||||
{TODOS.map((todo) => (
|
value={String(board.indicators.closingHeadcount)}
|
||||||
<li
|
hint={`moyenne ${board.indicators.averageHeadcount}`}
|
||||||
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"
|
<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
|
{LIST_LABELS[key]}
|
||||||
aria-hidden
|
</Link>
|
||||||
className="mt-1.5 size-2 flex-none rounded-full"
|
))}
|
||||||
style={{ background: todo.dot }}
|
</div>
|
||||||
/>
|
</div>
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block text-sm font-medium">
|
{board.lists[selected].length === 0 ? (
|
||||||
{todo.title}
|
<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>
|
||||||
<span className="mt-0.5 block text-xs leading-[var(--lh-prose)] text-ink-2">
|
) : null}
|
||||||
{todo.detail}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="tnum flex-none whitespace-nowrap text-micro text-ink-3">
|
|
||||||
{todo.when}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</PageBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -29,9 +29,12 @@ export const NAVIGATION: NavSection[] = [
|
|||||||
label: 'Aperçu',
|
label: 'Aperçu',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'taches', label: 'Tâches RH', href: '/' },
|
{ id: 'taches', label: 'Tâches RH', href: '/' },
|
||||||
{ id: 'entrees', label: 'Entrées et sorties' },
|
{ id: 'entrees', label: 'Entrées et sorties', href: '/?liste=entrees' },
|
||||||
{ id: 'profils', label: 'Profils incomplets', badge: '6' },
|
{ id: 'profils', label: 'Profils incomplets', href: '/?liste=profils' },
|
||||||
{ id: 'soldes-cp', label: 'Compteurs de congés payés' },
|
{ 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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,7 +45,7 @@ export const NAVIGATION: NavSection[] = [
|
|||||||
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
|
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
|
||||||
{ id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' },
|
{ id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' },
|
||||||
{ id: 'mois', label: 'Vue mois', href: '/planning/mois' },
|
{ id: 'mois', label: 'Vue mois', href: '/planning/mois' },
|
||||||
{ id: 'besoins', label: 'Besoins non couverts', badge: '5' },
|
{ id: 'besoins', label: 'Besoins non couverts' },
|
||||||
{ id: 'modeles', label: "Modèles d'ouverture" },
|
{ id: 'modeles', label: "Modèles d'ouverture" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -59,7 +62,7 @@ export const NAVIGATION: NavSection[] = [
|
|||||||
label: 'Congés',
|
label: 'Congés',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
|
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
|
||||||
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente', badge: '3' },
|
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente' },
|
||||||
{ id: 'politiques', label: 'Politiques de congés' },
|
{ id: 'politiques', label: 'Politiques de congés' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Indicateurs RH — PLAN.md §WP-09.
|
||||||
|
*
|
||||||
|
* Une règle gouverne ce module : **un indicateur doit être explicable**. Chaque
|
||||||
|
* fonction rend donc non seulement un chiffre mais les identifiants qui le
|
||||||
|
* composent, pour que l'écran puisse mener aux lignes sources. Un taux de
|
||||||
|
* turnover que personne ne peut ouvrir ne se corrige pas — il se conteste.
|
||||||
|
*
|
||||||
|
* Deuxième règle, moins visible mais plus coûteuse quand elle manque : les
|
||||||
|
* échéances se calculent en **fenêtres**, pas en dates dépassées. Un titre de
|
||||||
|
* séjour qui expire dans trois semaines doit remonter avant l'expiration, pas
|
||||||
|
* après.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HeadcountInput {
|
||||||
|
membershipId: string;
|
||||||
|
/** Début du contrat, date civile. */
|
||||||
|
startDate: string;
|
||||||
|
/** Fin du contrat, `null` si en cours. */
|
||||||
|
endDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeadcountResult {
|
||||||
|
/** Effectif présent en fin de période. */
|
||||||
|
closing: number;
|
||||||
|
entries: string[];
|
||||||
|
exits: string[];
|
||||||
|
/** Effectif moyen : (début + fin) / 2. */
|
||||||
|
average: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effectif, entrées et sorties sur une période.
|
||||||
|
*
|
||||||
|
* Un contrat compte comme présent dès lors qu'il **recouvre** la période : un
|
||||||
|
* salarié entré le 3 et sorti le 28 a été présent, même s'il n'était là ni au
|
||||||
|
* premier ni au dernier jour.
|
||||||
|
*/
|
||||||
|
export function headcount(
|
||||||
|
contracts: HeadcountInput[],
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): HeadcountResult {
|
||||||
|
const present = (isoDate: string) =>
|
||||||
|
contracts.filter(
|
||||||
|
(contract) =>
|
||||||
|
contract.startDate <= isoDate &&
|
||||||
|
(contract.endDate === null || contract.endDate >= isoDate),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
closing: present(to),
|
||||||
|
entries: contracts
|
||||||
|
.filter((contract) => contract.startDate >= from && contract.startDate <= to)
|
||||||
|
.map((contract) => contract.membershipId),
|
||||||
|
exits: contracts
|
||||||
|
.filter(
|
||||||
|
(contract) =>
|
||||||
|
contract.endDate !== null &&
|
||||||
|
contract.endDate >= from &&
|
||||||
|
contract.endDate <= to,
|
||||||
|
)
|
||||||
|
.map((contract) => contract.membershipId),
|
||||||
|
average: (present(from) + present(to)) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taux de rotation, en pourcentage.
|
||||||
|
*
|
||||||
|
* `(entrées + sorties) / 2 / effectif moyen`. La moyenne des deux mouvements
|
||||||
|
* est la définition usuelle en France ; compter seulement les départs
|
||||||
|
* sous-estime la rotation d'une équipe qui recrute autant qu'elle perd.
|
||||||
|
*
|
||||||
|
* Rend `null` plutôt que zéro sur un effectif nul : « 0 % de rotation » sur un
|
||||||
|
* établissement vide est une affirmation fausse, pas une absence de mouvement.
|
||||||
|
*/
|
||||||
|
export function turnoverRate(result: HeadcountResult): number | null {
|
||||||
|
if (result.average === 0) return null;
|
||||||
|
const movements = (result.entries.length + result.exits.length) / 2;
|
||||||
|
return Math.round((movements / result.average) * 1000) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbsenceInput {
|
||||||
|
membershipId: string;
|
||||||
|
/** Jours décomptés, tels que figés à la décision. */
|
||||||
|
days: number;
|
||||||
|
/** Vrai pour un arrêt relevant de la sécurité sociale. */
|
||||||
|
isSocialSecurity: boolean;
|
||||||
|
timeOffId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbsenteeismResult {
|
||||||
|
/** Jours d'absence, toutes natures. */
|
||||||
|
totalDays: number;
|
||||||
|
/** Jours d'arrêt maladie et assimilés. */
|
||||||
|
sickDays: number;
|
||||||
|
/** Taux d'absentéisme en pourcentage des jours théoriques. */
|
||||||
|
rate: number | null;
|
||||||
|
sourceIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absentéisme sur une période.
|
||||||
|
*
|
||||||
|
* Le taux se rapporte aux **jours théoriquement travaillés**, pas aux jours
|
||||||
|
* calendaires : rapporter à 30 jours donnerait un taux artificiellement bas et
|
||||||
|
* ferait passer un problème réel pour du bruit.
|
||||||
|
*/
|
||||||
|
export function absenteeism(
|
||||||
|
absences: AbsenceInput[],
|
||||||
|
theoreticalWorkedDays: number,
|
||||||
|
): AbsenteeismResult {
|
||||||
|
const totalDays = absences.reduce((sum, entry) => sum + entry.days, 0);
|
||||||
|
const sickDays = absences
|
||||||
|
.filter((entry) => entry.isSocialSecurity)
|
||||||
|
.reduce((sum, entry) => sum + entry.days, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDays: round(totalDays),
|
||||||
|
sickDays: round(sickDays),
|
||||||
|
rate:
|
||||||
|
theoreticalWorkedDays > 0
|
||||||
|
? Math.round((totalDays / theoreticalWorkedDays) * 1000) / 10
|
||||||
|
: null,
|
||||||
|
sourceIds: absences.map((entry) => entry.timeOffId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadlineInput {
|
||||||
|
id: string;
|
||||||
|
membershipId: string;
|
||||||
|
name: string;
|
||||||
|
/** Date d'échéance, civile. */
|
||||||
|
dueDate: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Deadline extends DeadlineInput {
|
||||||
|
/** Jours restants ; négatif si l'échéance est passée. */
|
||||||
|
daysLeft: number;
|
||||||
|
severity: 'PASSED' | 'URGENT' | 'SOON';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Échéances à venir dans une fenêtre.
|
||||||
|
*
|
||||||
|
* Les échéances **dépassées** sont conservées et remontées en tête : une
|
||||||
|
* période d'essai qu'on a laissé filer est une information 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.
|
||||||
|
*/
|
||||||
|
export function upcomingDeadlines(
|
||||||
|
entries: DeadlineInput[],
|
||||||
|
today: string,
|
||||||
|
windowDays: number,
|
||||||
|
urgentDays = 15,
|
||||||
|
): Deadline[] {
|
||||||
|
const now = new Date(`${today}T00:00:00Z`).getTime();
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map((entry) => {
|
||||||
|
const daysLeft = Math.round(
|
||||||
|
(new Date(`${entry.dueDate}T00:00:00Z`).getTime() - now) / DAY_MS,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
daysLeft,
|
||||||
|
severity:
|
||||||
|
daysLeft < 0
|
||||||
|
? ('PASSED' as const)
|
||||||
|
: daysLeft <= urgentDays
|
||||||
|
? ('URGENT' as const)
|
||||||
|
: ('SOON' as const),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.daysLeft <= windowDays)
|
||||||
|
.sort((a, b) => a.daysLeft - b.daysLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileCompleteness {
|
||||||
|
membershipId: string;
|
||||||
|
name: string;
|
||||||
|
/** Champs manquants, nommés pour l'écran. */
|
||||||
|
missing: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileInput {
|
||||||
|
membershipId: string;
|
||||||
|
name: string;
|
||||||
|
birthDate: unknown;
|
||||||
|
address: unknown;
|
||||||
|
city: unknown;
|
||||||
|
phone: unknown;
|
||||||
|
socialSecurityNumber: unknown;
|
||||||
|
iban: unknown;
|
||||||
|
hasContract: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champs manquants d'un dossier.
|
||||||
|
*
|
||||||
|
* La liste est **nommée**, pas comptée : « 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 vérifiés par leur présence chiffrée, jamais
|
||||||
|
* déchiffrés pour ce contrôle — savoir qu'une valeur existe n'exige pas de la
|
||||||
|
* lire.
|
||||||
|
*/
|
||||||
|
export function profileGaps(profile: ProfileInput): ProfileCompleteness {
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
if (!profile.birthDate) missing.push('date de naissance');
|
||||||
|
if (!profile.address) missing.push('adresse');
|
||||||
|
if (!profile.city) missing.push('ville');
|
||||||
|
if (!profile.phone) missing.push('téléphone');
|
||||||
|
if (!profile.socialSecurityNumber) missing.push('numéro de sécurité sociale');
|
||||||
|
if (!profile.iban) missing.push('IBAN');
|
||||||
|
if (!profile.hasContract) missing.push('contrat');
|
||||||
|
|
||||||
|
return {
|
||||||
|
membershipId: profile.membershipId,
|
||||||
|
name: profile.name,
|
||||||
|
missing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coût de main-d'œuvre — PLAN.md §7.5.
|
||||||
|
*
|
||||||
|
* Heures × taux horaire × (1 + taux de cotisations). Le taux patronal est un
|
||||||
|
* paramètre de l'établissement, jamais une constante : il varie d'un
|
||||||
|
* établissement à l'autre et d'une année sur l'autre.
|
||||||
|
*/
|
||||||
|
export function labourCost(
|
||||||
|
minutes: number,
|
||||||
|
hourlyRate: number,
|
||||||
|
employerContributionPercent: number,
|
||||||
|
): number {
|
||||||
|
const hours = minutes / 60;
|
||||||
|
const gross = hours * hourlyRate;
|
||||||
|
return Math.round(gross * (1 + employerContributionPercent / 100) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Données de démonstration
|
|
||||||
|
|
||||||
**Entièrement fictives.** Aucune donnée réelle de salarié ne doit entrer ici :
|
|
||||||
ce répertoire est versionné, lisible par tous, et n'offre aucune des garanties
|
|
||||||
que `PLAN.md` §3.6 exige pour un dossier RH — chiffrement au repos,
|
|
||||||
journalisation des accès, durées de conservation.
|
|
||||||
|
|
||||||
Ce module existe parce que les écrans ont été construits avant WP-01 (tenancy,
|
|
||||||
identité, base de données). Il tient la forme des données pour que la mise en
|
|
||||||
page soit réelle plutôt que devinée.
|
|
||||||
|
|
||||||
**Remplacement.** Chaque écran importe depuis `@/lib/demo/...` et rien d'autre.
|
|
||||||
Au fil des lots, ces imports sont remplacés un à un par des requêtes serveur
|
|
||||||
scopées. Le jour où le répertoire disparaît, il ne reste rien à nettoyer
|
|
||||||
ailleurs : c'est la seule raison pour laquelle il est isolé plutôt que dispersé
|
|
||||||
dans les composants.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import type { Tone } from '@/components/ui/Badge';
|
|
||||||
|
|
||||||
export interface DemoTile {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
tone: Tone;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TILES: DemoTile[] = [
|
|
||||||
{ label: 'Profils incomplets', value: '6', tone: 'warn' },
|
|
||||||
{ label: 'Fins de période d’essai', value: '2', tone: 'info' },
|
|
||||||
{ label: 'Documents à signer', value: '11', tone: 'accent' },
|
|
||||||
{ label: 'Titres de séjour à renouveler', value: '1', tone: 'danger' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface DemoEntry {
|
|
||||||
initials: string;
|
|
||||||
name: string;
|
|
||||||
job: string;
|
|
||||||
date: string;
|
|
||||||
state: string;
|
|
||||||
tone: Tone;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ENTRIES: DemoEntry[] = [
|
|
||||||
{
|
|
||||||
initials: 'YB',
|
|
||||||
name: 'Yanis Bouchard',
|
|
||||||
job: 'Vendeur univers maison · CDI 35 h',
|
|
||||||
date: '17 août',
|
|
||||||
state: 'Dossier complet',
|
|
||||||
tone: 'ok',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
initials: 'MR',
|
|
||||||
name: 'Maëlys Renard',
|
|
||||||
job: 'Hôtesse de caisse · CDD 24 h',
|
|
||||||
date: '24 août',
|
|
||||||
state: '2 pièces manquantes',
|
|
||||||
tone: 'warn',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
initials: 'TN',
|
|
||||||
name: 'Théo Nguyen',
|
|
||||||
job: 'Employé réserve · CDI 35 h',
|
|
||||||
date: '1 sept.',
|
|
||||||
state: 'Contrat à signer',
|
|
||||||
tone: 'info',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
initials: 'SL',
|
|
||||||
name: 'Sarah Lemoine',
|
|
||||||
job: 'Vendeuse décoration · CDI 30 h',
|
|
||||||
date: '7 sept.',
|
|
||||||
state: 'Dossier complet',
|
|
||||||
tone: 'ok',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface DemoTodo {
|
|
||||||
dot: string;
|
|
||||||
title: string;
|
|
||||||
detail: string;
|
|
||||||
when: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TODOS: DemoTodo[] = [
|
|
||||||
{
|
|
||||||
dot: 'var(--color-danger)',
|
|
||||||
title: '3 alertes de convention sur la semaine 33',
|
|
||||||
detail:
|
|
||||||
'Repos quotidien inférieur à 11 h pour Rémi Chartier (mardi) et amplitude dépassée pour Inès Bakhti (samedi).',
|
|
||||||
when: 'Aujourd’hui',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dot: 'var(--color-warn)',
|
|
||||||
title: 'Semaine 34 non publiée',
|
|
||||||
detail:
|
|
||||||
'La publication doit intervenir au moins 7 jours avant le premier créneau, soit avant le 10 août 18:00.',
|
|
||||||
when: 'J−0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dot: 'var(--color-info)',
|
|
||||||
title: '3 demandes de congés en attente',
|
|
||||||
detail:
|
|
||||||
'Nour Zaidi (5 jours), Rémi Chartier (2 jours), Alix Perron (1 jour).',
|
|
||||||
when: '2 j',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dot: 'var(--color-ink-3)',
|
|
||||||
title: '11 bulletins de paie à diffuser',
|
|
||||||
detail:
|
|
||||||
'Période de juillet 2026, contrôlée et validée par le service paie le 5 août.',
|
|
||||||
when: '5 j',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dot: 'var(--color-ink-3)',
|
|
||||||
title: 'Compteurs de récupération à solder',
|
|
||||||
detail:
|
|
||||||
'4 salariés dépassent 12 h de récupération non prise depuis plus de deux mois.',
|
|
||||||
when: '12 j',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
import { can, inScope } from '@/domain/access/authorize';
|
||||||
|
import {
|
||||||
|
absenteeism,
|
||||||
|
headcount,
|
||||||
|
labourCost,
|
||||||
|
profileGaps,
|
||||||
|
turnoverRate,
|
||||||
|
upcomingDeadlines,
|
||||||
|
type Deadline,
|
||||||
|
type ProfileCompleteness,
|
||||||
|
} from '@/domain/hr/indicators';
|
||||||
|
import { shiftMinutes } from '@/domain/counters/week';
|
||||||
|
import { monthDates, monthLabel, type Month } from '@/domain/planning/month';
|
||||||
|
import { zonedMidnight } from '@/domain/planning/week';
|
||||||
|
import { query } from '@/server/context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tableau de bord RH — WP-09.
|
||||||
|
*
|
||||||
|
* Trois exigences le gouvernent :
|
||||||
|
*
|
||||||
|
* 1. **Chaque indicateur est explicable.** Toute tuile porte l'identifiant de
|
||||||
|
* sa liste source ; un clic mène aux lignes. Un chiffre qu'on ne peut pas
|
||||||
|
* ouvrir ne se corrige pas, il se conteste.
|
||||||
|
* 2. **Les agrégats respectent le périmètre.** Un manager d'un établissement ne
|
||||||
|
* voit pas les mouvements d'un autre. Le filtrage est en base, pas à
|
||||||
|
* l'affichage.
|
||||||
|
* 3. **Les échéances remontent avant de tomber**, pas après.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ListKey =
|
||||||
|
| 'entrees'
|
||||||
|
| 'sorties'
|
||||||
|
| 'essais'
|
||||||
|
| 'profils'
|
||||||
|
| 'titres'
|
||||||
|
| 'absences'
|
||||||
|
| 'contrats';
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
key: ListKey;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone: 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'info';
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardRow {
|
||||||
|
id: string;
|
||||||
|
membershipId: string;
|
||||||
|
name: string;
|
||||||
|
detail: string;
|
||||||
|
date: string | null;
|
||||||
|
tone: 'neutral' | 'warn' | 'danger' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dashboard {
|
||||||
|
month: Month;
|
||||||
|
label: string;
|
||||||
|
monthParam: string;
|
||||||
|
previousParam: string;
|
||||||
|
nextParam: string;
|
||||||
|
location: { id: string; name: string } | null;
|
||||||
|
locations: Array<{ id: string; name: string }>;
|
||||||
|
tiles: Tile[];
|
||||||
|
indicators: {
|
||||||
|
closingHeadcount: number;
|
||||||
|
averageHeadcount: number;
|
||||||
|
turnoverRate: number | null;
|
||||||
|
absenceDays: number;
|
||||||
|
sickDays: number;
|
||||||
|
absenteeismRate: number | null;
|
||||||
|
plannedMinutes: number;
|
||||||
|
labourCost: number | null;
|
||||||
|
};
|
||||||
|
lists: Record<ListKey, DashboardRow[]>;
|
||||||
|
canSeeCost: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fenêtres d'échéance, en jours. */
|
||||||
|
const TRIAL_WINDOW = 45;
|
||||||
|
const PERMIT_WINDOW = 90;
|
||||||
|
|
||||||
|
export async function getDashboard(
|
||||||
|
month: Month,
|
||||||
|
locationId?: string,
|
||||||
|
): Promise<Dashboard> {
|
||||||
|
// `members.view` plutôt qu'une capacité dédiée : le tableau de bord n'est
|
||||||
|
// qu'une lecture agrégée de l'annuaire et de ses mouvements. Créer une
|
||||||
|
// capacité de plus donnerait un droit à administrer sans rien protéger de
|
||||||
|
// nouveau.
|
||||||
|
return query('members.view', async (db, actor) => {
|
||||||
|
const allLocations = await db.location.findMany({
|
||||||
|
where: { archivedAt: null },
|
||||||
|
select: { id: true, name: true, employerContributionRate: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le périmètre s'applique **avant** l'agrégation : un manager d'un
|
||||||
|
// établissement ne doit pas voir passer les mouvements d'un autre, même
|
||||||
|
// fondus dans un total.
|
||||||
|
const visible = allLocations.filter((location) =>
|
||||||
|
inScope(actor, { locationId: location.id }),
|
||||||
|
);
|
||||||
|
const location =
|
||||||
|
visible.find((candidate) => candidate.id === locationId) ??
|
||||||
|
visible[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const dates = monthDates(month);
|
||||||
|
const from = dates[0] as string;
|
||||||
|
const to = dates[dates.length - 1] as string;
|
||||||
|
|
||||||
|
const locationFilter = location ? { locationId: location.id } : {};
|
||||||
|
|
||||||
|
const contracts = await db.userContract.findMany({
|
||||||
|
where: locationFilter,
|
||||||
|
include: {
|
||||||
|
membership: {
|
||||||
|
include: { profile: { select: { firstName: true, lastName: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nameOf = (contract: (typeof contracts)[number]) =>
|
||||||
|
`${contract.membership.profile?.firstName ?? ''} ${contract.membership.profile?.lastName ?? contract.membership.employeeNumber}`.trim();
|
||||||
|
|
||||||
|
const counts = headcount(
|
||||||
|
contracts.map((contract) => ({
|
||||||
|
membershipId: contract.membershipId,
|
||||||
|
startDate: contract.startDate.toISOString().slice(0, 10),
|
||||||
|
endDate: contract.endDate?.toISOString().slice(0, 10) ?? null,
|
||||||
|
})),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberIds = [
|
||||||
|
...new Set(contracts.map((contract) => contract.membershipId)),
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Absences ---------------------------------------------------------
|
||||||
|
const timeOffs = await db.timeOff.findMany({
|
||||||
|
where: {
|
||||||
|
membershipId: { in: memberIds },
|
||||||
|
status: 'ACCEPTED',
|
||||||
|
startDate: { lte: new Date(`${to}T00:00:00Z`) },
|
||||||
|
endDate: { gte: new Date(`${from}T00:00:00Z`) },
|
||||||
|
},
|
||||||
|
include: { absenceType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const absence = absenteeism(
|
||||||
|
timeOffs.map((entry) => ({
|
||||||
|
membershipId: entry.membershipId,
|
||||||
|
days: Number(entry.countedDays?.toString() ?? '0'),
|
||||||
|
isSocialSecurity: entry.absenceType.isSocialSecurity,
|
||||||
|
timeOffId: entry.id,
|
||||||
|
})),
|
||||||
|
// Base théorique : effectif moyen × jours ouvrables du mois.
|
||||||
|
counts.average * workingDaysIn(dates),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Heures planifiées et coût ---------------------------------------
|
||||||
|
const canSeeCost = can(actor, 'planning.counters.view');
|
||||||
|
let plannedMinutes = 0;
|
||||||
|
let cost: number | null = null;
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
const timezone = 'Europe/Paris';
|
||||||
|
const shifts = await db.shift.findMany({
|
||||||
|
where: {
|
||||||
|
membershipId: { in: memberIds },
|
||||||
|
startAt: {
|
||||||
|
gte: zonedMidnight(from, timezone),
|
||||||
|
lt: zonedMidnight(
|
||||||
|
new Date(new Date(`${to}T00:00:00Z`).getTime() + 86_400_000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10),
|
||||||
|
timezone,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
membershipId: true,
|
||||||
|
startAt: true,
|
||||||
|
endAt: true,
|
||||||
|
breakMinutes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
plannedMinutes = shifts.reduce(
|
||||||
|
(sum, shift) =>
|
||||||
|
sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (canSeeCost) {
|
||||||
|
const rateBy = new Map(
|
||||||
|
contracts.map((contract) => [
|
||||||
|
contract.membershipId,
|
||||||
|
Number(contract.hourlyRate?.toString() ?? '0'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const employerRate = Number(
|
||||||
|
allLocations
|
||||||
|
.find((entry) => entry.id === location.id)
|
||||||
|
?.employerContributionRate.toString() ?? '0',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Un taux horaire absent vaut zéro et non une estimation : afficher un
|
||||||
|
// coût inventé serait pire que d'afficher un coût partiel.
|
||||||
|
const total = shifts.reduce((sum, shift) => {
|
||||||
|
const rate = rateBy.get(shift.membershipId ?? '') ?? 0;
|
||||||
|
if (rate === 0) return sum;
|
||||||
|
return (
|
||||||
|
sum +
|
||||||
|
labourCost(
|
||||||
|
shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes),
|
||||||
|
rate,
|
||||||
|
employerRate,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
cost = Math.round(total * 100) / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Échéances --------------------------------------------------------
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const trials: Deadline[] = upcomingDeadlines(
|
||||||
|
contracts
|
||||||
|
.filter((contract) => contract.trialEndDate && contract.status === 'ACTIVE')
|
||||||
|
.map((contract) => ({
|
||||||
|
id: contract.id,
|
||||||
|
membershipId: contract.membershipId,
|
||||||
|
name: nameOf(contract),
|
||||||
|
dueDate: (contract.trialEndDate as Date).toISOString().slice(0, 10),
|
||||||
|
label: 'Fin de période d’essai',
|
||||||
|
})),
|
||||||
|
today,
|
||||||
|
TRIAL_WINDOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
const permits = await db.workPermit.findMany({
|
||||||
|
where: { membershipId: { in: memberIds } },
|
||||||
|
include: {
|
||||||
|
membership: {
|
||||||
|
include: { profile: { select: { firstName: true, lastName: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const permitDeadlines: Deadline[] = upcomingDeadlines(
|
||||||
|
permits.map((permit) => ({
|
||||||
|
id: permit.id,
|
||||||
|
membershipId: permit.membershipId,
|
||||||
|
name: `${permit.membership.profile?.firstName ?? ''} ${permit.membership.profile?.lastName ?? permit.membership.employeeNumber}`.trim(),
|
||||||
|
dueDate: permit.expiresAt.toISOString().slice(0, 10),
|
||||||
|
label: permit.permitType,
|
||||||
|
})),
|
||||||
|
today,
|
||||||
|
PERMIT_WINDOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Dossiers incomplets ---------------------------------------------
|
||||||
|
const memberships = await db.membership.findMany({
|
||||||
|
where: { id: { in: memberIds }, archivedAt: null },
|
||||||
|
include: { profile: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const gaps: ProfileCompleteness[] = memberships
|
||||||
|
.map((membership) =>
|
||||||
|
profileGaps({
|
||||||
|
membershipId: membership.id,
|
||||||
|
name: `${membership.profile?.firstName ?? ''} ${membership.profile?.lastName ?? membership.employeeNumber}`.trim(),
|
||||||
|
birthDate: membership.profile?.birthDate ?? null,
|
||||||
|
address: membership.profile?.addressLine1 ?? null,
|
||||||
|
city: membership.profile?.city ?? null,
|
||||||
|
phone: membership.profile?.phone ?? null,
|
||||||
|
// Présence seulement : savoir qu'une valeur existe n'exige pas de la
|
||||||
|
// déchiffrer.
|
||||||
|
socialSecurityNumber: membership.profile?.socialSecurityNumberEnc ?? null,
|
||||||
|
iban: membership.profile?.ibanEnc ?? null,
|
||||||
|
hasContract: contracts.some(
|
||||||
|
(contract) => contract.membershipId === membership.id,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.filter((entry) => entry.missing.length > 0);
|
||||||
|
|
||||||
|
// --- Modifications de contrat ----------------------------------------
|
||||||
|
const amendments = await db.amendment.findMany({
|
||||||
|
where: {
|
||||||
|
userContractId: { in: contracts.map((contract) => contract.id) },
|
||||||
|
effectiveDate: {
|
||||||
|
gte: new Date(`${from}T00:00:00Z`),
|
||||||
|
lte: new Date(`${to}T00:00:00Z`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { effectiveDate: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const contractById = new Map(
|
||||||
|
contracts.map((contract) => [contract.id, contract]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const lists: Record<ListKey, DashboardRow[]> = {
|
||||||
|
entrees: contracts
|
||||||
|
.filter((contract) => counts.entries.includes(contract.membershipId))
|
||||||
|
.map((contract) => ({
|
||||||
|
id: contract.id,
|
||||||
|
membershipId: contract.membershipId,
|
||||||
|
name: nameOf(contract),
|
||||||
|
detail: contract.contractType,
|
||||||
|
date: contract.startDate.toISOString().slice(0, 10),
|
||||||
|
tone: 'info' as const,
|
||||||
|
})),
|
||||||
|
sorties: contracts
|
||||||
|
.filter((contract) => counts.exits.includes(contract.membershipId))
|
||||||
|
.map((contract) => ({
|
||||||
|
id: contract.id,
|
||||||
|
membershipId: contract.membershipId,
|
||||||
|
name: nameOf(contract),
|
||||||
|
detail: contract.contractType,
|
||||||
|
date: contract.endDate?.toISOString().slice(0, 10) ?? null,
|
||||||
|
tone: 'neutral' as const,
|
||||||
|
})),
|
||||||
|
essais: trials.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
membershipId: entry.membershipId,
|
||||||
|
name: entry.name,
|
||||||
|
detail:
|
||||||
|
entry.severity === 'PASSED'
|
||||||
|
? `Échéance dépassée de ${Math.abs(entry.daysLeft)} jours`
|
||||||
|
: `Dans ${entry.daysLeft} jours`,
|
||||||
|
date: entry.dueDate,
|
||||||
|
tone: entry.severity === 'PASSED' ? 'danger' : 'warn',
|
||||||
|
})),
|
||||||
|
titres: permitDeadlines.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
membershipId: entry.membershipId,
|
||||||
|
name: entry.name,
|
||||||
|
detail:
|
||||||
|
entry.severity === 'PASSED'
|
||||||
|
? `${entry.label} expiré depuis ${Math.abs(entry.daysLeft)} jours`
|
||||||
|
: `${entry.label} — dans ${entry.daysLeft} jours`,
|
||||||
|
date: entry.dueDate,
|
||||||
|
tone: entry.severity === 'PASSED' ? 'danger' : 'warn',
|
||||||
|
})),
|
||||||
|
profils: gaps.map((entry) => ({
|
||||||
|
id: entry.membershipId,
|
||||||
|
membershipId: entry.membershipId,
|
||||||
|
name: entry.name,
|
||||||
|
detail: `Manque : ${entry.missing.join(', ')}`,
|
||||||
|
date: null,
|
||||||
|
tone: 'warn' as const,
|
||||||
|
})),
|
||||||
|
absences: timeOffs.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
membershipId: entry.membershipId,
|
||||||
|
name:
|
||||||
|
memberships.find((member) => member.id === entry.membershipId)
|
||||||
|
?.profile?.firstName ?? 'Salarié',
|
||||||
|
// Le journal des absences ne porte jamais le motif médical : c'est une
|
||||||
|
// donnée de santé, et un tableau de bord n'en a aucun besoin.
|
||||||
|
detail: entry.absenceType.isSocialSecurity
|
||||||
|
? `Absence — ${entry.countedDays?.toString() ?? '0'} j`
|
||||||
|
: `${entry.absenceType.name} — ${entry.countedDays?.toString() ?? '0'} j`,
|
||||||
|
date: entry.startDate.toISOString().slice(0, 10),
|
||||||
|
tone: 'info' as const,
|
||||||
|
})),
|
||||||
|
contrats: amendments.map((amendment) => {
|
||||||
|
const contract = contractById.get(amendment.userContractId);
|
||||||
|
return {
|
||||||
|
id: amendment.id,
|
||||||
|
membershipId: contract?.membershipId ?? '',
|
||||||
|
name: contract ? nameOf(contract) : 'Salarié',
|
||||||
|
detail: amendment.reason ?? 'Avenant',
|
||||||
|
date: amendment.effectiveDate.toISOString().slice(0, 10),
|
||||||
|
tone: 'neutral' as const,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { formatMonthParam, nextMonth, previousMonth } = await import(
|
||||||
|
'@/domain/planning/month'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
label: monthLabel(month),
|
||||||
|
monthParam: formatMonthParam(month),
|
||||||
|
previousParam: formatMonthParam(previousMonth(month)),
|
||||||
|
nextParam: formatMonthParam(nextMonth(month)),
|
||||||
|
location: location ? { id: location.id, name: location.name } : null,
|
||||||
|
locations: visible.map(({ id, name }) => ({ id, name })),
|
||||||
|
tiles: [
|
||||||
|
{
|
||||||
|
key: 'profils',
|
||||||
|
label: 'Profils incomplets',
|
||||||
|
value: String(lists.profils.length),
|
||||||
|
tone: lists.profils.length > 0 ? 'warn' : 'ok',
|
||||||
|
hint: 'Dossiers auxquels il manque une pièce',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'essais',
|
||||||
|
label: 'Fins de période d’essai',
|
||||||
|
value: String(lists.essais.length),
|
||||||
|
tone: lists.essais.some((row) => row.tone === 'danger')
|
||||||
|
? 'danger'
|
||||||
|
: 'info',
|
||||||
|
hint: `Échéances dans les ${TRIAL_WINDOW} jours`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'titres',
|
||||||
|
label: 'Titres de séjour',
|
||||||
|
value: String(lists.titres.length),
|
||||||
|
tone: lists.titres.some((row) => row.tone === 'danger')
|
||||||
|
? 'danger'
|
||||||
|
: lists.titres.length > 0
|
||||||
|
? 'warn'
|
||||||
|
: 'ok',
|
||||||
|
hint: `Échéances dans les ${PERMIT_WINDOW} jours`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'entrees',
|
||||||
|
label: 'Entrées du mois',
|
||||||
|
value: String(lists.entrees.length),
|
||||||
|
tone: 'accent',
|
||||||
|
hint: 'Contrats démarrés sur la période',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sorties',
|
||||||
|
label: 'Sorties du mois',
|
||||||
|
value: String(lists.sorties.length),
|
||||||
|
tone: 'neutral',
|
||||||
|
hint: 'Contrats terminés sur la période',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contrats',
|
||||||
|
label: 'Avenants',
|
||||||
|
value: String(lists.contrats.length),
|
||||||
|
tone: 'neutral',
|
||||||
|
hint: 'Modifications de contrat prenant effet ce mois',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indicators: {
|
||||||
|
closingHeadcount: counts.closing,
|
||||||
|
averageHeadcount: counts.average,
|
||||||
|
turnoverRate: turnoverRate(counts),
|
||||||
|
absenceDays: absence.totalDays,
|
||||||
|
sickDays: absence.sickDays,
|
||||||
|
absenteeismRate: absence.rate,
|
||||||
|
plannedMinutes,
|
||||||
|
labourCost: cost,
|
||||||
|
},
|
||||||
|
lists,
|
||||||
|
canSeeCost,
|
||||||
|
};
|
||||||
|
}, locationId ? { locationId } : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jours ouvrables d'un mois : base théorique de l'absentéisme. */
|
||||||
|
function workingDaysIn(dates: string[]): number {
|
||||||
|
return dates.filter((date) => {
|
||||||
|
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
|
||||||
|
return day !== 0;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
@@ -58,3 +58,34 @@ test('un manager voit le brouillon et peut le publier', async ({ page }) => {
|
|||||||
await expect(page.getByText('Brouillon').first()).toBeVisible();
|
await expect(page.getByText('Brouillon').first()).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Publier' }).first()).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Publier' }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('un salarié voit un accueil adapté, pas un tableau de bord vide', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signIn(page, 'salarie@example.test');
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Lui servir une erreur d'autorisation sur la page d'accueil serait absurde ;
|
||||||
|
// lui montrer des indicateurs vides le serait tout autant.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Aperçu' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Mon planning')).toBeVisible();
|
||||||
|
await expect(page.getByText('Effectif en fin de mois')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('Rotation')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un manager ne voit que les établissements de son périmètre', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signIn(page, 'manager.nantes@example.test');
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Le périmètre s'applique avant l'agrégation : les mouvements d'un autre
|
||||||
|
// établissement ne doivent pas transparaître, même fondus dans un total.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: 'Nantes Atlantis', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: 'Rennes Alma', exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tableau de bord RH.
|
||||||
|
*
|
||||||
|
* Le critère qui compte : **un indicateur doit être explicable**. Un chiffre
|
||||||
|
* qu'on ne peut pas ouvrir ne se corrige pas, il se conteste.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('chaque tuile mène à ses lignes sources', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
|
||||||
|
|
||||||
|
// Les tuiles sont des liens, pas des chiffres décoratifs. On vise celle du
|
||||||
|
// contenu, pas l'entrée homonyme de la barre latérale.
|
||||||
|
const tile = page
|
||||||
|
.locator('main')
|
||||||
|
.getByRole('link', { name: /Profils incomplets/ })
|
||||||
|
.first();
|
||||||
|
await tile.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/liste=profils/);
|
||||||
|
await expect(
|
||||||
|
page.locator('main').getByText('Profils incomplets').first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la liste nomme ce qui manque plutôt que de le compter', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/?liste=profils');
|
||||||
|
|
||||||
|
// « 6 profils incomplets » n'aide personne à agir ; « il manque l'IBAN » se
|
||||||
|
// règle en un message.
|
||||||
|
await expect(page.getByText(/Manque :/).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les indicateurs agrégés sont affichés', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page.getByText('Effectif en fin de mois')).toBeVisible();
|
||||||
|
await expect(page.getByText('Rotation')).toBeVisible();
|
||||||
|
await expect(page.getByText('Absentéisme')).toBeVisible();
|
||||||
|
await expect(page.getByText('Heures planifiées')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chaque ligne mène à la fiche du salarié', async ({ page }) => {
|
||||||
|
await page.goto('/?liste=profils');
|
||||||
|
|
||||||
|
const first = page
|
||||||
|
.locator('section')
|
||||||
|
.filter({ hasText: 'Profils incomplets' })
|
||||||
|
.locator('li a')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (await first.isVisible()) {
|
||||||
|
await expect(first).toHaveAttribute('href', /\/equipe\//);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la navigation entre listes conserve le mois et l’établissement', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('main')
|
||||||
|
.getByRole('link', { name: '← Mois précédent' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Attendre la navigation avant de lire l'URL : la lire tout de suite rendrait
|
||||||
|
// celle d'avant le clic.
|
||||||
|
await expect(page).toHaveURL(/mois=/);
|
||||||
|
const month = new URL(page.url()).searchParams.get('mois');
|
||||||
|
expect(month).toBeTruthy();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('main')
|
||||||
|
.getByRole('link', { name: 'Journal des absences' })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
// Changer de liste ne doit pas ramener au mois courant : on perdrait le
|
||||||
|
// contexte à chaque clic.
|
||||||
|
await expect(page).toHaveURL(new RegExp(`mois=${month}`));
|
||||||
|
});
|
||||||
@@ -51,10 +51,13 @@ test('le thème bascule et survit à un rechargement', async ({ page }) => {
|
|||||||
test('une entrée de navigation non construite mène à un écran explicite', async ({
|
test('une entrée de navigation non construite mène à un écran explicite', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/');
|
// La barre latérale n'affiche que la section courante : on l'ouvre d'abord.
|
||||||
await page.getByRole('link', { name: 'Profils incomplets' }).click();
|
await page.goto('/conges');
|
||||||
|
// « 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();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Profils incomplets' }),
|
page.getByRole('heading', { name: 'Politiques de congés' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
// `exact` évite de heurter l'annonceur de route de Next, qui répète le titre
|
// `exact` évite de heurter l'annonceur de route de Next, qui répète le titre
|
||||||
// du document — « Écran à venir · PlanFlow ».
|
// du document — « Écran à venir · PlanFlow ».
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
absenteeism,
|
||||||
|
headcount,
|
||||||
|
labourCost,
|
||||||
|
profileGaps,
|
||||||
|
turnoverRate,
|
||||||
|
upcomingDeadlines,
|
||||||
|
type HeadcountInput,
|
||||||
|
} from '@/domain/hr/indicators';
|
||||||
|
|
||||||
|
describe('effectif', () => {
|
||||||
|
const contracts: HeadcountInput[] = [
|
||||||
|
{ membershipId: 'a', startDate: '2024-01-01', endDate: null },
|
||||||
|
{ membershipId: 'b', startDate: '2024-01-01', endDate: '2026-08-15' },
|
||||||
|
{ membershipId: 'c', startDate: '2026-08-03', endDate: null },
|
||||||
|
{ membershipId: 'd', startDate: '2027-01-01', endDate: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('compte les présents en fin de période', () => {
|
||||||
|
const result = headcount(contracts, '2026-08-01', '2026-08-31');
|
||||||
|
// a et c sont là au 31 ; b est parti le 15 ; d n'est pas encore arrivé.
|
||||||
|
expect(result.closing).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relève les entrées et les sorties de la période', () => {
|
||||||
|
const result = headcount(contracts, '2026-08-01', '2026-08-31');
|
||||||
|
expect(result.entries).toEqual(['c']);
|
||||||
|
expect(result.exits).toEqual(['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compte un salarié entré et sorti dans la période', () => {
|
||||||
|
// Présent, même s'il n'était là ni au premier ni au dernier jour.
|
||||||
|
const result = headcount(
|
||||||
|
[{ membershipId: 'e', startDate: '2026-08-03', endDate: '2026-08-28' }],
|
||||||
|
'2026-08-01',
|
||||||
|
'2026-08-31',
|
||||||
|
);
|
||||||
|
expect(result.entries).toEqual(['e']);
|
||||||
|
expect(result.exits).toEqual(['e']);
|
||||||
|
expect(result.closing).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calcule l’effectif moyen sur les deux bornes', () => {
|
||||||
|
const result = headcount(contracts, '2026-08-01', '2026-08-31');
|
||||||
|
// 2 présents au 1er (a, b), 2 au 31 (a, c).
|
||||||
|
expect(result.average).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rotation', () => {
|
||||||
|
it('moyenne entrées et sorties', () => {
|
||||||
|
// Compter seulement les départs sous-estime la rotation d'une équipe qui
|
||||||
|
// recrute autant qu'elle perd.
|
||||||
|
const result = headcount(
|
||||||
|
[
|
||||||
|
{ membershipId: 'a', startDate: '2024-01-01', endDate: null },
|
||||||
|
{ membershipId: 'b', startDate: '2024-01-01', endDate: null },
|
||||||
|
{ membershipId: 'c', startDate: '2024-01-01', endDate: null },
|
||||||
|
{ membershipId: 'd', startDate: '2024-01-01', endDate: '2026-08-20' },
|
||||||
|
{ membershipId: 'e', startDate: '2026-08-10', endDate: null },
|
||||||
|
],
|
||||||
|
'2026-08-01',
|
||||||
|
'2026-08-31',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1 entrée, 1 sortie, effectif moyen (4 + 4) / 2 = 4 → 25 %.
|
||||||
|
expect(turnoverRate(result)).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne prétend pas 0 % sur un effectif nul', () => {
|
||||||
|
// « 0 % de rotation » sur un établissement vide est une affirmation
|
||||||
|
// fausse, pas une absence de mouvement.
|
||||||
|
const result = headcount([], '2026-08-01', '2026-08-31');
|
||||||
|
expect(turnoverRate(result)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('absentéisme', () => {
|
||||||
|
const absences = [
|
||||||
|
{ membershipId: 'a', days: 5, isSocialSecurity: false, timeOffId: 't1' },
|
||||||
|
{ membershipId: 'b', days: 3, isSocialSecurity: true, timeOffId: 't2' },
|
||||||
|
{ membershipId: 'c', days: 1.5, isSocialSecurity: true, timeOffId: 't3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('sépare les arrêts du reste', () => {
|
||||||
|
const result = absenteeism(absences, 200);
|
||||||
|
expect(result.totalDays).toBe(9.5);
|
||||||
|
expect(result.sickDays).toBe(4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rapporte le taux aux jours théoriquement travaillés', () => {
|
||||||
|
// Rapporter à 30 jours calendaires donnerait un taux artificiellement bas
|
||||||
|
// et ferait passer un problème réel pour du bruit.
|
||||||
|
expect(absenteeism(absences, 200).rate).toBe(4.8);
|
||||||
|
expect(absenteeism(absences, 100).rate).toBe(9.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rend les identifiants sources', () => {
|
||||||
|
// Un indicateur qu'on ne peut pas ouvrir ne se corrige pas, il se conteste.
|
||||||
|
expect(absenteeism(absences, 200).sourceIds).toEqual(['t1', 't2', 't3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne calcule pas de taux sans base', () => {
|
||||||
|
expect(absenteeism(absences, 0).rate).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('échéances', () => {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
membershipId: 'a',
|
||||||
|
name: 'Awa Diallo',
|
||||||
|
dueDate: '2026-08-20',
|
||||||
|
label: 'Titre de séjour',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
membershipId: 'b',
|
||||||
|
name: 'Yanis Trabelsi',
|
||||||
|
dueDate: '2026-07-30',
|
||||||
|
label: 'Période d’essai',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p3',
|
||||||
|
membershipId: 'c',
|
||||||
|
name: 'Léa Nguyen',
|
||||||
|
dueDate: '2026-11-30',
|
||||||
|
label: 'Titre de séjour',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('remonte les échéances de la fenêtre', () => {
|
||||||
|
const result = upcomingDeadlines(entries, '2026-08-10', 60);
|
||||||
|
expect(result.map((entry) => entry.id)).toEqual(['p2', 'p1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('place les échéances dépassées en tête', () => {
|
||||||
|
// Une période d'essai qu'on a laissé filer est plus urgente qu'une
|
||||||
|
// échéance à venir : la masquer est ce qui la rend coûteuse.
|
||||||
|
const result = upcomingDeadlines(entries, '2026-08-10', 60);
|
||||||
|
expect(result[0]?.severity).toBe('PASSED');
|
||||||
|
expect(result[0]?.daysLeft).toBe(-11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distingue l’urgent du prochain', () => {
|
||||||
|
const result = upcomingDeadlines(entries, '2026-08-10', 200);
|
||||||
|
expect(result.find((entry) => entry.id === 'p1')?.severity).toBe('URGENT');
|
||||||
|
expect(result.find((entry) => entry.id === 'p3')?.severity).toBe('SOON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exclut ce qui dépasse la fenêtre', () => {
|
||||||
|
const result = upcomingDeadlines(entries, '2026-08-10', 30);
|
||||||
|
expect(result.map((entry) => entry.id)).not.toContain('p3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('traite la borne exacte comme incluse', () => {
|
||||||
|
const result = upcomingDeadlines(entries, '2026-08-10', 10);
|
||||||
|
// 20 août − 10 août = 10 jours : exactement la fenêtre.
|
||||||
|
expect(result.map((entry) => entry.id)).toContain('p1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complétude des dossiers', () => {
|
||||||
|
const complete = {
|
||||||
|
membershipId: 'a',
|
||||||
|
name: 'Sofia Marchetti',
|
||||||
|
birthDate: new Date('1990-01-01'),
|
||||||
|
address: '1 rue de la Paix',
|
||||||
|
city: 'Nantes',
|
||||||
|
phone: '00 00 00 00 00',
|
||||||
|
socialSecurityNumber: Buffer.from('chiffré'),
|
||||||
|
iban: Buffer.from('chiffré'),
|
||||||
|
hasContract: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('ne signale rien sur un dossier complet', () => {
|
||||||
|
expect(profileGaps(complete).missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nomme ce qui manque plutôt que de le compter', () => {
|
||||||
|
// « 6 profils incomplets » n'aide personne à agir ; « il manque l'IBAN de
|
||||||
|
// trois salariés » se règle en un message.
|
||||||
|
const gaps = profileGaps({ ...complete, iban: null, phone: null });
|
||||||
|
expect(gaps.missing).toEqual(['téléphone', 'IBAN']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signale un salarié sans contrat', () => {
|
||||||
|
expect(profileGaps({ ...complete, hasContract: false }).missing).toEqual([
|
||||||
|
'contrat',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contrôle la présence du NIR sans le déchiffrer', () => {
|
||||||
|
// Savoir qu'une valeur existe n'exige pas de la lire.
|
||||||
|
const gaps = profileGaps({ ...complete, socialSecurityNumber: null });
|
||||||
|
expect(gaps.missing).toEqual(['numéro de sécurité sociale']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('coût de main-d’œuvre', () => {
|
||||||
|
it('applique le taux de cotisations de l’établissement', () => {
|
||||||
|
// 10 h à 12 € avec 42 % de charges : 120 × 1,42.
|
||||||
|
expect(labourCost(600, 12, 42)).toBe(170.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suit un taux différent d’un établissement à l’autre', () => {
|
||||||
|
// Le taux patronal est un paramètre, jamais une constante.
|
||||||
|
expect(labourCost(600, 12, 0)).toBe(120);
|
||||||
|
expect(labourCost(600, 12, 50)).toBe(180);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user