WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe

Le planning quitte le module de démonstration : la grille lit `WeeklySchedule`
et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des
salariés fictifs disparaissent.

Modèle
- `TeamMember` : rattachement d'un salarié à une équipe, distinct de
  `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un
  salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état
  de départ de toute semaine en construction.
- RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`.

Temps
- `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO,
  jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1
  de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1.
- `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant
  visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf
  heures à minuit donnerait 10 h locales.
- La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure
  d'été 167 — vérifié par test.

Écritures
- Création, modification, suppression de créneau ; publication et dépublication
  **par équipe**, avec verrou optimiste sur `version` : deux managers sur la
  même grille est le cas normal, pas l'exception.
- Chevauchement refusé en transaction, pas seulement dans le formulaire.
- Modifier une semaine publiée exige `planning.edit_published`, capacité que le
  rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu.
- Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace
  **avant** l'effacement, sinon l'état supprimé serait perdu.

Lecture
- `planning.view_unpublished` filtre en base : sans cette capacité, les
  brouillons ne sont pas chargés du tout. Un test vérifie que les horaires
  n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait.
- Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée
  réelle plutôt que figée à 06 h–21 h.

Vérification
- 26 tests unitaires sur le repérage des semaines et la mise en grille.
- Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ;
  et ce que voient un salarié et un manager sur la même semaine.
- `scripts/dev-db.sh` : la base de développement est éphémère dans cet
  environnement, la remonter ne doit pas être une redécouverte.

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-07 23:43:48 +00:00
1 parent e9d459366d
commit 023f236881
27 files changed
+3373 -674

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
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();
});
+1 -1
View File
@@ -45,7 +45,7 @@ test('connexion, navigation, puis déconnexion', async ({ page }) => {
await page.getByRole('link', { name: 'Plannings' }).click();
await expect(
page.getByRole('heading', { name: /Planning · semaine 33/ }),
page.getByRole('heading', { name: /Planning · semaine \d+/ }),
).toBeVisible();
await page.getByRole('button', { name: 'Déconnexion' }).click();
+6 -4
View File
@@ -6,14 +6,16 @@ test('les six écrans se chargent et affichent leur contenu', async ({ page }) =
await page.getByRole('link', { name: 'Plannings' }).click();
await expect(
page.getByRole('heading', { name: /Planning · semaine 33/ }),
page.getByRole('heading', { name: /Planning · semaine \d+/ }),
).toBeVisible();
// La grille doit porter des créneaux, pas seulement son ossature.
await expect(page.getByText('Congés payés').first()).toBeVisible();
// La grille doit porter des créneaux venus de la base, pas seulement son
// ossature : le seed plante deux semaines sur trois équipes.
await expect(page.getByRole('heading', { name: 'Vente' })).toBeVisible();
await expect(page.getByText('09:00–17:00').first()).toBeVisible();
await page.getByRole('link', { name: 'Vue jour' }).click();
await expect(
page.getByRole('heading', { name: /Planning · mercredi 12 août/ }),
page.getByRole('heading', { name: /^Planning · / }),
).toBeVisible();
await page.getByRole('link', { name: 'Équipe', exact: true }).click();
+116
View File
@@ -0,0 +1,116 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
/**
* Parcours du planning : poser un créneau, publier, dépublier.
*
* Le test travaille sur une semaine **lointaine** plutôt que la semaine
* courante : celle du seed change à chaque exécution, et un test qui dépend de
* la date du jour finit par échouer un lundi de janvier sans que personne ne
* sache pourquoi.
*
* La base de test n'est pas remise à zéro entre deux passages : la semaine est
* donc vidée au début, sans quoi le second passage partirait d'un état publié
* ou déjà rempli.
*/
const WEEK = '2027-W20';
function venteSection(page: Page): Locator {
return page
.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Vente' }) });
}
async function resetWeek(page: Page): Promise<void> {
await page.goto(`/planning/semaine?semaine=${WEEK}`);
const section = venteSection(page);
await expect(section).toBeVisible();
const unpublish = section.getByRole('button', { name: 'Dépublier' });
if (await unpublish.isVisible()) {
await unpublish.click();
await expect(section.getByText('Brouillon')).toBeVisible();
}
const remove = section.getByRole('button', { name: 'Supprimer le créneau' });
for (let guard = 0; guard < 30; guard += 1) {
const before = await remove.count();
if (before === 0) break;
await remove.first().click();
// Compter plutôt que regarder un libellé : c'est la disparition du
// créneau qui est attendue, et elle est observable sans ambiguïté.
await expect(remove).toHaveCount(before - 1);
}
await expect(remove).toHaveCount(0);
}
async function addShift(
page: Page,
section: Locator,
times: { start: string; end: string; pause?: string },
): Promise<Locator> {
const row = section
.locator('div')
.filter({ hasText: /Camille Ferrand/ })
.first();
await row.getByRole('button', { name: /\+ Créneau/ }).first().click();
const composer = page.locator('form').filter({ hasText: 'Poste' }).first();
await composer.getByLabel('Début').fill(times.start);
await composer.getByLabel('Fin').fill(times.end);
await composer.getByLabel('Pause (min)').fill(times.pause ?? '0');
await composer.getByRole('button', { name: 'Ajouter' }).click();
return composer;
}
test('un manager pose un créneau, publie, puis dépublie', async ({ page }) => {
await resetWeek(page);
const section = venteSection(page);
// Publier une semaine vide n'a pas de sens : rien à annoncer aux salariés.
await section.getByRole('button', { name: 'Publier' }).click();
await expect(section.getByText(/ne contient aucun créneau/)).toBeVisible();
await addShift(page, section, {
start: '08:30',
end: '16:30',
pause: '45',
});
await expect(section.getByText('08:30–16:30').first()).toBeVisible();
// 8 h d'amplitude moins 45 min de pause : le compteur doit dire 7 h 15,
// pas 8 h. C'est la déduction de la pause qui est vérifiée ici.
await expect(section.getByText('7 h 15').first()).toBeVisible();
// Un créneau qui recouvre le premier compterait deux fois en heures. Le
// refus vient du serveur, en transaction, pas du formulaire.
const composer = await addShift(page, section, {
start: '12:00',
end: '20:00',
});
await expect(composer.getByText(/recouvre cette plage/)).toBeVisible();
await composer.getByRole('button', { name: 'Annuler' }).click();
await section.getByRole('button', { name: 'Publier' }).click();
await expect(section.getByText('Publiée')).toBeVisible();
await section.getByRole('button', { name: 'Dépublier' }).click();
await expect(section.getByText('Brouillon')).toBeVisible();
});
test('la navigation de semaine change la grille', async ({ page }) => {
await page.goto(`/planning/semaine?semaine=${WEEK}`);
await expect(
page.getByRole('heading', { name: 'Planning · semaine 20' }),
).toBeVisible();
await page.getByRole('link', { name: 'Semaine suivante →' }).click();
await expect(
page.getByRole('heading', { name: 'Planning · semaine 21' }),
).toBeVisible();
await page.getByRole('link', { name: '← Semaine précédente' }).click();
await expect(
page.getByRole('heading', { name: 'Planning · semaine 20' }),
).toBeVisible();
});
+7 -2
View File
@@ -67,10 +67,13 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
// L'effectif vient de la base, pas du module de démonstration.
await expect(page.getByText('E0001')).toBeVisible();
// Nom unique par exécution : la base de test n'est pas remise à zéro entre
// deux passages, et un nom fixe finirait par désigner plusieurs salariés.
const matricule = `E9${Date.now() % 100000}`;
const nom = `Sanscompte${matricule}`;
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.locator('input[name="firstName"]').fill('Sans');
await form.locator('input[name="lastName"]').fill('Compte');
await form.locator('input[name="lastName"]').fill(nom);
await form.locator('input[name="employeeNumber"]').fill(matricule);
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
@@ -87,5 +90,7 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
).toBeVisible();
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
// applicatif doit tout de même figurer nommément au registre du personnel.
await expect(page.getByRole('link', { name: /Sans Compte/ })).toBeVisible();
await expect(
page.getByRole('link', { name: new RegExp(`Sans ${nom}`) }),
).toBeVisible();
});