diff --git a/docker-compose.yml b/docker-compose.yml
index a521919..7786d64 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -37,6 +37,18 @@ services:
APP_URL: ${APP_URL:-http://localhost:3000}
ports:
- '${APP_PORT:-3000}:3000'
+ healthcheck:
+ test:
+ [
+ 'CMD',
+ 'node',
+ '-e',
+ "fetch('http://127.0.0.1:3000/api/sante').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
+ ]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
volumes:
db-data:
diff --git a/playwright.config.ts b/playwright.config.ts
index 7276195..3758a96 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -18,7 +18,23 @@ export default defineConfig({
},
projects: [
- { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ // Certains environnements fournissent déjà un Chromium dont la révision
+ // ne correspond pas à celle qu'attend cette version de Playwright.
+ // PLAYWRIGHT_CHROMIUM_PATH permet de le réutiliser plutôt que d'en
+ // télécharger un second.
+ ...(process.env.PLAYWRIGHT_CHROMIUM_PATH
+ ? {
+ launchOptions: {
+ executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH,
+ },
+ }
+ : {}),
+ },
+ },
],
webServer: {
diff --git a/src/app/api/sante/route.ts b/src/app/api/sante/route.ts
new file mode 100644
index 0000000..c97ab7a
--- /dev/null
+++ b/src/app/api/sante/route.ts
@@ -0,0 +1,24 @@
+import { NextResponse } from 'next/server';
+
+import { checkDatabase } from '@/server/health';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * Point de santé, destiné aux sondes de conteneur.
+ *
+ * Il ne dit que « la base répond » : ni version, ni schéma, ni compte. Une
+ * sonde est joignable sans authentification, elle ne doit donc rien apprendre
+ * à qui la interroge.
+ */
+export async function GET() {
+ const database = await checkDatabase();
+
+ return NextResponse.json(
+ { status: database.ok ? 'ok' : 'degraded' },
+ {
+ status: database.ok ? 200 : 503,
+ headers: { 'cache-control': 'no-store' },
+ },
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
deleted file mode 100644
index 079c263..0000000
--- a/src/app/page.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { checkDatabase } from '@/server/health';
-
-export const dynamic = 'force-dynamic';
-
-export default async function HomePage() {
- const database = await checkDatabase();
-
- return (
-
-
-
PlanFlow
-
- Socle applicatif — lot WP-00. Les écrans métier arrivent aux lots suivants.
-
-
-
-
-
-
Base de données
-
- {database.ok ? 'connectée' : `indisponible — ${database.error}`}
-
-
-
-
- );
-}
diff --git a/src/components/planning/CounterStrip.tsx b/src/components/planning/CounterStrip.tsx
index d314341..be4a338 100644
--- a/src/components/planning/CounterStrip.tsx
+++ b/src/components/planning/CounterStrip.tsx
@@ -12,6 +12,8 @@ export interface CounterStripProps {
counters: WeekCountersView;
/** Vrai si le contrat est au forfait jours : pas de comparaison horaire. */
forfaitJours?: boolean;
+ /** Ligne des besoins sans titulaire : il n'y a pas de contrat à comparer. */
+ unassigned?: boolean;
className?: string;
}
@@ -32,8 +34,23 @@ function Cell({ label, value }: { label: string; value: string }) {
export function CounterStrip({
counters,
forfaitJours = false,
+ unassigned = false,
className,
}: CounterStripProps) {
+ // Un besoin non couvert n'a pas de contrat : afficher un écart le compare à
+ // zéro et fait lire « +14 h » comme un dépassement, alors que c'est
+ // simplement le volume à pourvoir.
+ if (unassigned) {
+ return (
+
+ À pourvoir
+
+ {counters.plannedLabel}
+
+
+ );
+ }
+
if (forfaitJours) {
return (
diff --git a/src/components/planning/ShiftChip.tsx b/src/components/planning/ShiftChip.tsx
index 4c77431..770781b 100644
--- a/src/components/planning/ShiftChip.tsx
+++ b/src/components/planning/ShiftChip.tsx
@@ -87,7 +87,13 @@ export function ShiftChip({
{alert ? (
-
+ // Le motif est tronqué dans une cellule étroite : le `title` garde le
+ // texte complet accessible, sinon l'alerte dit qu'il y a un problème
+ // sans jamais dire lequel.
+
▲
{alert}
diff --git a/src/components/planning/WeekGrid.tsx b/src/components/planning/WeekGrid.tsx
index 335c59e..1609616 100644
--- a/src/components/planning/WeekGrid.tsx
+++ b/src/components/planning/WeekGrid.tsx
@@ -106,6 +106,7 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx
index f573dc8..3f269c4 100644
--- a/src/components/shell/AppShell.tsx
+++ b/src/components/shell/AppShell.tsx
@@ -11,7 +11,9 @@ import { cx } from '@/lib/cx';
import {
isActive,
NAVIGATION,
+ placeholderHref,
sectionForPath,
+ sectionHref,
type NavItem,
} from '@/components/shell/navigation';
@@ -36,12 +38,11 @@ export function AppShell({ children }: AppShellProps) {
{NAVIGATION.map((entry) => {
- const target = entry.items.find((item) => item.href);
const current = entry.id === section.id;
return (
+
{content}
);
diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts
index 9aa3d07..979123a 100644
--- a/src/components/shell/navigation.ts
+++ b/src/components/shell/navigation.ts
@@ -85,14 +85,57 @@ export const NAVIGATION: NavSection[] = [
/** Section contenant la route donnée, ou la première par défaut. */
export function sectionForPath(pathname: string): NavSection {
- const match = NAVIGATION.find((section) =>
- section.items.some((item) => item.href && isActive(item.href, pathname)),
- );
+ const active = activeItem(pathname);
+ const match = active
+ ? NAVIGATION.find((section) =>
+ section.items.some((item) => item.id === active.id),
+ )
+ : undefined;
return match ?? (NAVIGATION[0] as NavSection);
}
+/** Chemin vers l'écran d'attente d'une entrée non encore construite. */
+export function placeholderHref(label: string): string {
+ return `/bientot?ecran=${encodeURIComponent(label)}`;
+}
+
+/**
+ * Destination d'un onglet de section.
+ *
+ * Une section dont aucun écran n'est construit ne doit pas renvoyer à
+ * l'accueil : le clic donnerait l'impression que l'onglet est cassé. Elle mène
+ * à l'écran d'attente de sa première entrée, qui dit ce qui viendra là.
+ */
+export function sectionHref(section: NavSection): string {
+ const built = section.items.find((item) => item.href);
+ if (built?.href) return built.href;
+ const first = section.items[0];
+ return first ? placeholderHref(first.label) : '/';
+}
+
+export function matches(href: string, pathname: string): boolean {
+ // Un lien d'ancre pointe à l'intérieur d'une page déjà représentée par une
+ // autre entrée : il ne concourt pas.
+ if (href.includes('#')) return false;
+
+ if (href === '/') return pathname === '/';
+ return pathname === href || pathname.startsWith(`${href}/`);
+}
+
+/**
+ * Entrée à mettre en évidence pour une route donnée.
+ *
+ * `/equipe` et `/equipe/camille-ferrand` correspondent tous deux quand on est
+ * sur une fiche. On retient la **plus spécifique** : deux entrées allumées à la
+ * fois, et le repère de position ne repère plus rien.
+ */
+export function activeItem(pathname: string): NavItem | undefined {
+ return NAVIGATION.flatMap((section) => section.items)
+ .filter((item) => item.href && matches(item.href, pathname))
+ .sort((a, b) => (b.href as string).length - (a.href as string).length)[0];
+}
+
export function isActive(href: string, pathname: string): boolean {
- const base = href.split('#')[0] ?? href;
- if (base === '/') return pathname === '/';
- return pathname === base || pathname.startsWith(`${base}/`);
+ const active = activeItem(pathname);
+ return active?.href === href;
}
diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts
new file mode 100644
index 0000000..70530c5
--- /dev/null
+++ b/tests/e2e/navigation.spec.ts
@@ -0,0 +1,60 @@
+import { expect, test } from '@playwright/test';
+
+test('les six écrans se chargent et affichent leur contenu', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
+
+ await page.getByRole('link', { name: 'Plannings' }).click();
+ await expect(
+ page.getByRole('heading', { name: /Planning · semaine 33/ }),
+ ).toBeVisible();
+ // La grille doit porter des créneaux, pas seulement son ossature.
+ await expect(page.getByText('Congés payés').first()).toBeVisible();
+
+ await page.getByRole('link', { name: 'Vue jour' }).click();
+ await expect(
+ page.getByRole('heading', { name: /Planning · mercredi 12 août/ }),
+ ).toBeVisible();
+
+ await page.getByRole('link', { name: 'Équipe', exact: true }).click();
+ await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible();
+
+ await page.getByRole('link', { name: 'Camille Ferrand' }).click();
+ await expect(
+ page.getByRole('heading', { name: 'Camille Ferrand' }),
+ ).toBeVisible();
+
+ await page.getByRole('link', { name: 'Congés' }).click();
+ await expect(
+ page.getByRole('heading', { name: 'Calendrier des absences' }),
+ ).toBeVisible();
+});
+
+test('le thème bascule et survit à un rechargement', async ({ page }) => {
+ await page.goto('/');
+ const html = page.locator('html');
+ await expect(html).toHaveAttribute('data-theme', 'light');
+
+ await page.getByRole('button', { name: /^Thème/ }).click();
+ await expect(html).toHaveAttribute('data-theme', 'dark');
+
+ // Le script inline doit reposer le thème avant le premier rendu : sans lui,
+ // la page reviendrait en clair puis basculerait — un flash blanc.
+ await page.reload();
+ await expect(html).toHaveAttribute('data-theme', 'dark');
+});
+
+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();
+ await expect(
+ page.getByRole('heading', { name: 'Profils incomplets' }),
+ ).toBeVisible();
+ // `exact` évite de heurter l'annonceur de route de Next, qui répète le titre
+ // du document — « Écran à venir · PlanFlow ».
+ await expect(
+ page.getByText('Écran à venir', { exact: true }),
+ ).toBeVisible();
+});
diff --git a/tests/unit/navigation.test.ts b/tests/unit/navigation.test.ts
new file mode 100644
index 0000000..abaab0b
--- /dev/null
+++ b/tests/unit/navigation.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ activeItem,
+ isActive,
+ matches,
+ NAVIGATION,
+ sectionForPath,
+} from '@/components/shell/navigation';
+
+describe('matches', () => {
+ it('reconnaît la racine sans capturer tout le site', () => {
+ expect(matches('/', '/')).toBe(true);
+ expect(matches('/', '/equipe')).toBe(false);
+ });
+
+ it('couvre les sous-routes', () => {
+ expect(matches('/equipe', '/equipe')).toBe(true);
+ expect(matches('/equipe', '/equipe/camille-ferrand')).toBe(true);
+ expect(matches('/equipe', '/equipements')).toBe(false);
+ });
+
+ it('écarte les liens d’ancre', () => {
+ expect(matches('/conges#attente', '/conges')).toBe(false);
+ });
+});
+
+describe('activeItem', () => {
+ it('retient la correspondance la plus spécifique', () => {
+ // Sur une fiche, « Membres » (/equipe) et « Fiche salarié » correspondent
+ // tous deux ; c'est la fiche qui doit s'allumer.
+ expect(activeItem('/equipe/camille-ferrand')?.id).toBe('fiche');
+ expect(activeItem('/equipe')?.id).toBe('membres');
+ });
+
+ it('ne renvoie rien pour une route hors navigation', () => {
+ expect(activeItem('/inconnu')).toBeUndefined();
+ });
+});
+
+describe('isActive', () => {
+ it('n’allume qu’une entrée par écran', () => {
+ const targets = NAVIGATION.flatMap((section) => section.items)
+ .map((item) => item.href)
+ .filter(
+ (href): href is string =>
+ typeof href === 'string' && !href.includes('#'),
+ );
+
+ for (const pathname of [...targets, '/equipe/sarah-lemoine']) {
+ const lit = NAVIGATION.flatMap((section) => section.items).filter(
+ (item) => item.href && isActive(item.href, pathname),
+ );
+ expect(lit.length, `${pathname} allume ${lit.length} entrées`).toBe(1);
+ }
+ });
+});
+
+describe('sectionForPath', () => {
+ it('trouve la section portant la route courante', () => {
+ expect(sectionForPath('/planning/semaine').id).toBe('plannings');
+ expect(sectionForPath('/equipe/camille-ferrand').id).toBe('equipe');
+ expect(sectionForPath('/conges').id).toBe('conges');
+ });
+
+ it('retombe sur la première section pour une route inconnue', () => {
+ expect(sectionForPath('/inconnu').id).toBe('apercu');
+ });
+});