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
@@ -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 ».
|
||||
|
||||
Reference in new issue
Block a user