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
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -29,9 +29,12 @@ export const NAVIGATION: NavSection[] = [
|
||||
label: 'Aperçu',
|
||||
items: [
|
||||
{ id: 'taches', label: 'Tâches RH', href: '/' },
|
||||
{ id: 'entrees', label: 'Entrées et sorties' },
|
||||
{ id: 'profils', label: 'Profils incomplets', badge: '6' },
|
||||
{ id: 'soldes-cp', label: 'Compteurs de congés payés' },
|
||||
{ id: 'entrees', label: 'Entrées et sorties', href: '/?liste=entrees' },
|
||||
{ id: 'profils', label: 'Profils incomplets', href: '/?liste=profils' },
|
||||
{ id: 'titres', label: 'Titres de séjour', href: '/?liste=titres' },
|
||||
// Ancre plutôt que route : `/conges` appartient à la section Congés, et
|
||||
// deux entrées allumées pour un même écran rendent la navigation muette.
|
||||
{ id: 'soldes-cp', label: 'Compteurs de congés payés', href: '/conges#compteurs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -42,7 +45,7 @@ export const NAVIGATION: NavSection[] = [
|
||||
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
|
||||
{ id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' },
|
||||
{ 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" },
|
||||
],
|
||||
},
|
||||
@@ -59,7 +62,7 @@ export const NAVIGATION: NavSection[] = [
|
||||
label: 'Congés',
|
||||
items: [
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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.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 ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: 'Profils incomplets' }).click();
|
||||
// La barre latérale n'affiche que la section courante : on l'ouvre d'abord.
|
||||
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(
|
||||
page.getByRole('heading', { name: 'Profils incomplets' }),
|
||||
page.getByRole('heading', { name: 'Politiques de congés' }),
|
||||
).toBeVisible();
|
||||
// `exact` évite de heurter l'annonceur de route de Next, qui répète le titre
|
||||
// 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