Files
Claude 10a9c923e2 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
2026-08-08 12:40:19 +00:00

92 lines
3.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from '@playwright/test';
import {
formatWeekParam,
isoWeekOf,
previousIsoWeek,
} from '../../src/domain/planning/week';
/**
* Ce que chaque rôle voit du planning.
*
* Ces tests se connectent eux-mêmes : ils ne peuvent pas réutiliser la session
* partagée de la direction, d'où le projet « anonyme ».
*/
async function signIn(page: Page, email: string) {
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(email);
await page.getByLabel('Mot de passe').fill('planflow-demo-2026');
await page.getByRole('button', { name: 'Se connecter' }).click();
// Attendre la sortie de l'écran de connexion : naviguer tout de suite
// annulerait la redirection en cours, et le test repartirait déconnecté.
await expect(page).not.toHaveURL(/\/connexion/);
}
const current = isoWeekOf(new Date());
const CURRENT_WEEK = formatWeekParam(current);
const PUBLISHED_WEEK = formatWeekParam(previousIsoWeek(current));
test('un salarié ne voit pas une semaine non publiée', async ({ page }) => {
await signIn(page, 'salarie@example.test');
// Le seed laisse la semaine courante en brouillon et publie la précédente.
await page.goto(`/planning/semaine?semaine=${CURRENT_WEEK}`);
await expect(page.getByText(/n’est pas encore publié/).first()).toBeVisible();
// Le masquage n'est pas cosmétique : les créneaux ne sont pas chargés, donc
// absents du HTML. Un simple `display: none` les y laisserait.
expect(await page.content()).not.toContain('09:00–17:00');
// Ni bouton de publication ni ajout de créneau : les capacités manquent.
await expect(page.getByRole('button', { name: 'Publier' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /\+ Créneau/ })).toHaveCount(0);
});
test('un salarié voit la semaine publiée', async ({ page }) => {
await signIn(page, 'salarie@example.test');
await page.goto(`/planning/semaine?semaine=${PUBLISHED_WEEK}`);
await expect(page.getByText('Publiée').first()).toBeVisible();
await expect(page.getByText('09:00–17:00').first()).toBeVisible();
});
test('un manager voit le brouillon et peut le publier', async ({ page }) => {
await signIn(page, 'manager.nantes@example.test');
await page.goto(`/planning/semaine?semaine=${CURRENT_WEEK}`);
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);
});