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

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

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

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

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

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

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

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

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

No files matched your search

+471
View File
@@ -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;
}