# LST-62 (0.2) — Socle front : shell + auto-détection des layers Nuxt — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Poser l'ossature frontend modulaire (shell `app/`, code partagé `shared/`, auto-détection des layers `modules/*/`, sidebar dynamique alimentée par `/api/sidebar`, redirection des routes désactivées) **sans déplacer aucune page métier** — l'app reste « plate » et la navigation ne régresse pas.

**Architecture:** On s'aligne sur le pattern Starseed : `srcDir: '.'`, layouts/middleware sous `frontend/app/`, composables/stores transverses sous `frontend/shared/` (auto-importés via `imports.dirs`), et un scan `readdirSync('modules/')` qui ajoute chaque `modules/*/` à `extends`. Le backend `/api/modules` + `/api/sidebar` existe déjà (LST-56). On ajoute un **gate de rôle minimal** côté `SidebarProvider`/`SidebarFilter` (ROLE_ADMIN) pour préserver la visibilité de l'Administration sans attendre le RBAC fin (#1.2). Les items **contextuels** (Kanban/Groupes/Archives), **feature-flag** (Documents, Mail) et **user-flag** (Mes absences) restent rendus côté layout, hors `/api/sidebar`.

**Tech Stack:** Nuxt 4.3, Vue 3.5, Pinia 3, @malio/layer-ui 1.7, @nuxtjs/i18n 10, @nuxt/icon — côté back PHP 8.4 / Symfony 8 / API Platform 4 / PHPUnit 13.

## Global Constraints

- **Aucune page métier déplacée** : `frontend/pages/` reste tel quel ; on ne crée AUCUN `frontend/modules/<x>/pages/` peuplé en 0.2 (le dossier `modules/` est créé vide pour le scan).
- **Zéro régression de navigation** : tous les liens actuels restent atteignables et correctement gardés (admin reste admin-only).
- **Auto-import Nuxt** : les composants/pages référencent les composables/stores **par nom** (`useApi()`, `useAuthStore()`), jamais par chemin → déplacer un fichier entre deux dossiers auto-scannés est transparent. Toujours le vérifier par un `typecheck` après déplacement.
- **Commits** : format `<type>(<scope>) : <message>` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic (message, body, trailers).
- **PHP** : `declare(strict_types=1);` en tête ; tests via `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit …`.
- **TS** : strict, 4 espaces d'indentation, pas de `any`.
- **Pas de migration BDD** dans ce lot (aucune entité touchée).

## Décisions de conception (actées avec le PO)

1. **Gate de rôle minimal côté back** : les items/sections réservés (`/team-absences`, `/admin`) portent une clé `roles` dans `config/sidebar.php` ; `SidebarProvider` passe les rôles de l'utilisateur courant à `SidebarFilter` qui masque ce qui n'est pas autorisé. Ce n'est **pas** le RBAC fin (#1.2) — juste ROLE_ADMIN/ROLE_USER.
2. **Items contextuels / feature-flag / user-flag hors `/api/sidebar`** : Kanban/Groupes/Archives (contexte `currentProjectId`), Documents (`shareEnabled`), Mail (+ badge non lus), Mes absences (`isEmployee`) restent rendus par le layout comme aujourd'hui.
3. **Délta cosmétique assumé** : la sidebar dynamique regroupe le Tableau de bord avec « Mes tâches / Projets / Suivi de temps » sous un même en-tête, et le bloc statique (contextuel/flag/Mes absences) s'insère après cette première section. Léger réordonnancement visuel, **à valider**, harmonisé en #60 (Finition Malio). Aucun lien perdu.

## Vérification (pas de runner de tests JS dans ce projet)

- **Back (Task 1)** : vraie TDD PHPUnit.
- **Front (Tasks 2-7)** : la verif = `typecheck` Nuxt (en LECTURE différentielle, cf. ci-dessous) + smoke test runtime. Commandes :
  - Typecheck : `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
  - Runtime : dev server `make dev-nuxt` (port 3002, proxy `/api` → nginx) ; vérifier manuellement la navigation + `curl` des endpoints via nginx (`http://localhost:8082/api/...`). Les containers sont up.

> **⚠️ `nuxt typecheck` n'est PAS un gate vert sur ce projet (constat 2026-06-19).** Le baseline Lesstime est déjà rouge (~230 lignes `error TS`), et le projet de référence **Starseed (même Nuxt 4.3.1, même layout `shared/` + `srcDir: '.'`) ship en prod avec 325 erreurs `error TS`**. Ces erreurs sont des classes structurelles attendues, pas des régressions :
> - dans `shared/composables/*` et `shared/stores/*` : `Cannot find name 'ref'/'useApi'/'useRoute'/'navigateTo'/'defineStore'/'useToast'/'useNuxtApp'…` — Nuxt 4 type le dossier `shared/` sous un `tsconfig.shared.json` isolé sans les globals d'auto-import, alors que `imports.dirs` les rend bien disponibles au RUNTIME (vérifié dans `.nuxt/imports.d.ts`). Starseed a exactement ces 15 erreurs et fonctionne.
> - dans `nuxt.config.ts` : `node:fs`/`node:path`/`__dirname`/`process` (pas de `@types/node` — comme Starseed) ; ce fichier est compilé par Nuxt au runtime, pas par `tsc`.
> - dans `useApi.ts` : `Property 'url' does not exist…` (préexistant, code forké de Starseed).
>
> **Le vrai gate front** = (1) **ZÉRO erreur `Cannot find module '~/shared/…'` / chemin cassé** (sinon un import a vraiment été cassé par un déplacement) ; (2) les auto-imports attendus présents dans `.nuxt/imports.d.ts` ; (3) smoke runtime sur le dev server. Ne JAMAIS s'arrêter sur les classes d'erreurs structurelles ci-dessus — elles sont identiques à la référence Starseed.

---

### Task 1: Backend — gate de rôle dans la sidebar (`roles`) + config complète

**Files:**
- Modify: `src/Shared/Domain/Sidebar/SidebarFilter.php` (signature + gate `roles`)
- Modify: `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` (injecter `Security`, passer les rôles)
- Modify: `config/sidebar.php` (navigation globale + section Administration gated ROLE_ADMIN ; retrait de `/absences` qui reste client-side)
- Modify: `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` (adapter à la nouvelle signature + cas `roles`)
- Modify: `tests/Functional/Shared/SidebarEndpointTest.php` (vérifier le gate admin)

**Interfaces:**
- Produces : `SidebarFilter::filter(array $sections, array $activeModuleIds, array $activeRoles = []): array`. Règles ajoutées : une **section** ou un **item** portant une clé `roles` (non vide) n'est conservé que si `$activeRoles` contient au moins un des rôles listés ; sinon la section/l'item est retiré (les `to` des items retirés **par rôle** ne sont PAS ajoutés à `disabledRoutes` — `disabledRoutes` reste réservé au filtrage **par module**, qui pilote la redirection front). Les clés internes `module` et `roles` sont retirées de la sortie.
- Consumes : `Symfony\Bundle\SecurityBundle\Security` (rôles via `getUser()`).

- [ ] **Step 1: Adapter le test unitaire existant + ajouter les cas `roles`**

Remplace INTÉGRALEMENT `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` par :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Shared\Sidebar;

use App\Shared\Domain\Sidebar\SidebarFilter;
use PHPUnit\Framework\TestCase;

/**
 * @internal
 */
final class SidebarFilterTest extends TestCase
{
    public function testItemWithoutModuleIsAlwaysVisible(): void
    {
        $sections = [
            ['label' => 'sidebar.core.section', 'icon' => 'mdi:home', 'items' => [
                ['label' => 'sidebar.core.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard'],
            ]],
        ];

        $result = SidebarFilter::filter($sections, [], ['ROLE_USER']);

        self::assertCount(1, $result['sections']);
        self::assertSame('/', $result['sections'][0]['items'][0]['to']);
        self::assertSame([], $result['disabledRoutes']);
        self::assertArrayNotHasKey('module', $result['sections'][0]['items'][0]);
    }

    public function testItemWithInactiveModuleIsHiddenAndRouteDisabled(): void
    {
        $sections = [
            ['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [
                ['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'],
            ]],
        ];

        $result = SidebarFilter::filter($sections, [], ['ROLE_USER']);

        self::assertSame([], $result['sections']);
        self::assertSame(['/time-tracking'], $result['disabledRoutes']);
    }

    public function testItemWithActiveModuleIsVisible(): void
    {
        $sections = [
            ['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [
                ['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'],
            ]],
        ];

        $result = SidebarFilter::filter($sections, ['time_tracking'], ['ROLE_USER']);

        self::assertCount(1, $result['sections']);
        self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']);
        self::assertSame([], $result['disabledRoutes']);
    }

    public function testSectionWithRolesIsHiddenWhenRoleMissing(): void
    {
        $sections = [
            ['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [
                ['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'],
            ]],
        ];

        $result = SidebarFilter::filter($sections, [], ['ROLE_USER']);

        self::assertSame([], $result['sections']);
        // Filtrage par rôle => PAS de disabledRoutes (réservé au filtrage par module).
        self::assertSame([], $result['disabledRoutes']);
    }

    public function testSectionWithRolesIsVisibleWhenRolePresent(): void
    {
        $sections = [
            ['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [
                ['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'],
            ]],
        ];

        $result = SidebarFilter::filter($sections, [], ['ROLE_USER', 'ROLE_ADMIN']);

        self::assertCount(1, $result['sections']);
        self::assertSame('/admin', $result['sections'][0]['items'][0]['to']);
        self::assertArrayNotHasKey('roles', $result['sections'][0]);
    }

    public function testItemWithRolesIsHiddenWhenRoleMissing(): void
    {
        $sections = [
            ['label' => 'sidebar.hr.section', 'icon' => 'mdi:calendar', 'items' => [
                ['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group', 'roles' => ['ROLE_ADMIN']],
                ['label' => 'sidebar.hr.x', 'to' => '/x', 'icon' => 'mdi:x'],
            ]],
        ];

        $result = SidebarFilter::filter($sections, [], ['ROLE_USER']);

        self::assertCount(1, $result['sections']);
        self::assertCount(1, $result['sections'][0]['items']);
        self::assertSame('/x', $result['sections'][0]['items'][0]['to']);
        self::assertArrayNotHasKey('roles', $result['sections'][0]['items'][0]);
    }
}
```

- [ ] **Step 2: Lancer le test, vérifier l'échec**

Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php`
Expected: FAIL — `filter()` actuel n'accepte que 2 args / ne gère pas `roles` (erreur d'arité ou assertions rouges).

- [ ] **Step 3: Étendre `SidebarFilter`**

Remplace INTÉGRALEMENT `src/Shared/Domain/Sidebar/SidebarFilter.php` par :

```php
<?php

declare(strict_types=1);

namespace App\Shared\Domain\Sidebar;

final class SidebarFilter
{
    /**
     * @param list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sections
     * @param list<string>                                                                                                                                               $activeModuleIds
     * @param list<string>                                                                                                                                               $activeRoles
     *
     * @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
     */
    public static function filter(array $sections, array $activeModuleIds, array $activeRoles = []): array
    {
        $outSections    = [];
        $disabledRoutes = [];

        foreach ($sections as $section) {
            // Gate de rôle au niveau section (ne pollue pas disabledRoutes : réservé au filtrage module).
            if (!self::rolesSatisfied($section['roles'] ?? null, $activeRoles)) {
                continue;
            }

            $items = [];
            foreach ($section['items'] as $item) {
                // Gate de rôle au niveau item.
                if (!self::rolesSatisfied($item['roles'] ?? null, $activeRoles)) {
                    continue;
                }

                // Filtrage par module actif (pilote la redirection front via disabledRoutes).
                $module = $item['module'] ?? null;
                if (null !== $module && !in_array($module, $activeModuleIds, true)) {
                    $disabledRoutes[] = $item['to'];

                    continue;
                }

                $items[] = ['label' => $item['label'], 'to' => $item['to'], 'icon' => $item['icon']];
            }

            if ([] !== $items) {
                $outSections[] = ['label' => $section['label'], 'icon' => $section['icon'], 'items' => $items];
            }
        }

        return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes];
    }

    /**
     * @param list<string>|null $required
     * @param list<string>      $activeRoles
     */
    private static function rolesSatisfied(?array $required, array $activeRoles): bool
    {
        if (null === $required || [] === $required) {
            return true;
        }

        foreach ($required as $role) {
            if (in_array($role, $activeRoles, true)) {
                return true;
            }
        }

        return false;
    }
}
```

- [ ] **Step 4: Lancer le test unitaire, vérifier le vert**

Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php`
Expected: PASS (6 tests).

- [ ] **Step 5: Injecter les rôles dans `SidebarProvider`**

Remplace INTÉGRALEMENT `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` par :

```php
<?php

declare(strict_types=1);

namespace App\Shared\Infrastructure\ApiPlatform\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Domain\Sidebar\SidebarFilter;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final readonly class SidebarProvider implements ProviderInterface
{
    public function __construct(
        #[Autowire('%kernel.project_dir%')]
        private string $projectDir,
        private Security $security,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): SidebarResource
    {
        /** @var list<class-string> $moduleClasses */
        $moduleClasses = require $this->projectDir.'/config/modules.php';

        /** @var list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sidebar */
        $sidebar = require $this->projectDir.'/config/sidebar.php';

        $user  = $this->security->getUser();
        $roles = null !== $user ? $user->getRoles() : [];

        $filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles));

        $dto                 = new SidebarResource();
        $dto->sections       = $filtered['sections'];
        $dto->disabledRoutes = $filtered['disabledRoutes'];

        return $dto;
    }
}
```

- [ ] **Step 6: Compléter `config/sidebar.php`**

Remplace INTÉGRALEMENT `config/sidebar.php` par (icônes alignées sur le layout actuel ; `/absences` retiré car gardé client-side via `isEmployee`) :

```php
<?php

declare(strict_types=1);

/*
 * Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
 * Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif),
 * `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal,
 * le RBAC fin par permission arrive en #1.2).
 * Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
 * (Mes absences) restent rendus côté layout, hors de cet endpoint.
 * Les labels sont des clés i18n (sidebar.<domaine>.<item>).
 */
return [
    [
        'label' => 'sidebar.general.section',
        'icon'  => 'mdi:view-dashboard-outline',
        'items' => [
            ['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
            ['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
            ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
            ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
        ],
    ],
    [
        'label' => 'sidebar.admin.section',
        'icon'  => 'mdi:cog-outline',
        'roles' => ['ROLE_ADMIN'],
        'items' => [
            ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
            ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'],
        ],
    ],
];
```

- [ ] **Step 7: Renforcer le test fonctionnel sidebar (gate admin)**

Remplace INTÉGRALEMENT `tests/Functional/Shared/SidebarEndpointTest.php` par :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Functional\Shared;

use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

/**
 * @internal
 */
final class SidebarEndpointTest extends WebTestCase
{
    public function testSidebarRequiresAuthentication(): void
    {
        $client = self::createClient();
        $client->request('GET', '/api/sidebar');

        self::assertResponseStatusCodeSame(401);
    }

    public function testSidebarReturnsSectionsForAuthenticatedUser(): void
    {
        $client = self::createClient();
        $em     = self::getContainer()->get('doctrine.orm.entity_manager');

        $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
        $client->loginUser($user);

        $client->request('GET', '/api/sidebar');

        self::assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        self::assertArrayHasKey('sections', $data);
        self::assertArrayHasKey('disabledRoutes', $data);
        self::assertNotEmpty($data['sections']);
    }

    public function testAdminSectionHiddenForNonAdmin(): void
    {
        $client = self::createClient();
        $em     = self::getContainer()->get('doctrine.orm.entity_manager');

        $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); // ROLE_USER
        $client->loginUser($user);

        $client->request('GET', '/api/sidebar');
        $data   = json_decode($client->getResponse()->getContent(), true);
        $labels = array_column($data['sections'], 'label');

        self::assertNotContains('sidebar.admin.section', $labels);
    }

    public function testAdminSectionVisibleForAdmin(): void
    {
        $client = self::createClient();
        $em     = self::getContainer()->get('doctrine.orm.entity_manager');

        $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); // ROLE_ADMIN
        $client->loginUser($user);

        $client->request('GET', '/api/sidebar');
        $data   = json_decode($client->getResponse()->getContent(), true);
        $labels = array_column($data['sections'], 'label');

        self::assertContains('sidebar.admin.section', $labels);
    }
}
```

- [ ] **Step 8: Lancer la suite complète, vérifier le vert**

Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS (les 110 tests précédents adaptés + nouveaux cas). Si `admin`/`alice` n'existent pas en base de test, vérifier les fixtures (`admin`/`admin`, `alice`/`alice` d'après CLAUDE.md).

- [ ] **Step 9: php-cs-fixer + commit**

Run: `make php-cs-fixer-allow-risky`
```bash
git add src/Shared/Domain/Sidebar/SidebarFilter.php src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php config/sidebar.php tests/Unit/Shared/Sidebar/SidebarFilterTest.php tests/Functional/Shared/SidebarEndpointTest.php
git commit -m "feat(sidebar) : add role gate to sidebar provider and global nav config"
```

---

### Task 2: Frontend — types + composables partagés (`useModules`, `useSidebar`)

**Files:**
- Create: `frontend/shared/types/sidebar.ts`
- Create: `frontend/shared/composables/useModules.ts`
- Create: `frontend/shared/composables/useSidebar.ts`

> Note : à cette étape `shared/` n'est pas encore dans `imports.dirs` (fait en Task 4). Ces fichiers sont créés ici mais référencés/auto-importés seulement après Task 4 ; le typecheck final de validation se fait donc en fin de Task 4. Cette task se termine sans verif runtime (pur ajout de fichiers).

**Interfaces:**
- Produces :
  - `useModules(): { activeModuleIds: Ref<string[]>, loaded: Ref<boolean>, loadModules(): Promise<void>, isModuleActive(id: string): boolean, resetModules(): void }`
  - `useSidebar(): { sections: Ref<SidebarSection[]>, disabledRoutes: Ref<string[]>, loaded: Ref<boolean>, loadSidebar(): Promise<void>, isRouteDisabled(path: string): boolean, resetSidebar(): void }`
  - `SidebarSection`, `SidebarItem` (types).
- Consumes : `useApi()` (auto-importé, déplacé en Task 3 — toujours appelé par nom).

- [ ] **Step 1: Créer les types**

`frontend/shared/types/sidebar.ts` :

```ts
export type SidebarItem = {
    label: string
    to: string
    icon: string
}

export type SidebarSection = {
    label: string
    icon: string
    items: SidebarItem[]
}
```

- [ ] **Step 2: Créer `useModules`**

`frontend/shared/composables/useModules.ts` (état singleton au niveau module) :

```ts
const activeModuleIds = ref<string[]>([])
const loaded = ref(false)

export function useModules() {
    async function loadModules(): Promise<void> {
        const api = useApi()
        const data = await api.get<{ modules: string[] }>('/modules', {}, { toast: false })
        activeModuleIds.value = data.modules ?? []
        loaded.value = true
    }

    function isModuleActive(id: string): boolean {
        return activeModuleIds.value.includes(id)
    }

    function resetModules(): void {
        activeModuleIds.value = []
        loaded.value = false
    }

    return { activeModuleIds, loaded, loadModules, isModuleActive, resetModules }
}
```

> Vérifier la signature réelle de `useApi().get` (Task 3 / source actuelle) : `get<T>(url, query?, options?)`. L'option `{ toast: false }` doit exister dans `ApiFetchOptions` ; si la clé diffère (ex. `toastSuccessKey`/`toast`), aligner sur la signature réelle de `useApi.ts`. Si aucune option « silencieux » n'existe, passer `{}`.

- [ ] **Step 3: Créer `useSidebar`**

`frontend/shared/composables/useSidebar.ts` :

```ts
import type { SidebarSection } from '~/shared/types/sidebar'

const sections = ref<SidebarSection[]>([])
const disabledRoutes = ref<string[]>([])
const loaded = ref(false)

export function useSidebar() {
    async function loadSidebar(): Promise<void> {
        const api = useApi()
        const data = await api.get<{ sections: SidebarSection[]; disabledRoutes: string[] }>(
            '/sidebar', {}, { toast: false },
        )
        sections.value = data.sections ?? []
        disabledRoutes.value = data.disabledRoutes ?? []
        loaded.value = true
    }

    function isRouteDisabled(path: string): boolean {
        return disabledRoutes.value.some(
            (disabled) => path === disabled || path.startsWith(disabled + '/'),
        )
    }

    function resetSidebar(): void {
        sections.value = []
        disabledRoutes.value = []
        loaded.value = false
    }

    return { sections, disabledRoutes, loaded, loadSidebar, isRouteDisabled, resetSidebar }
}
```

- [ ] **Step 4: Commit**

```bash
git add frontend/shared/types/sidebar.ts frontend/shared/composables/useModules.ts frontend/shared/composables/useSidebar.ts
git commit -m "feat(front) : add shared useModules/useSidebar composables and sidebar types"
```

---

### Task 3: Frontend — déplacer `useApi` et les stores transverses vers `shared/`

**Files:**
- Move: `frontend/composables/useApi.ts` → `frontend/shared/composables/useApi.ts`
- Move: `frontend/stores/auth.ts` → `frontend/shared/stores/auth.ts`
- Move: `frontend/stores/ui.ts` → `frontend/shared/stores/ui.ts`

> `timer.ts` et `mail.ts` **restent** dans `frontend/stores/` (domaines métier non encore migrés en module). On ne déplace que les deux stores transverses (auth, ui) + `useApi`. La résolution effective (auto-import depuis `shared/`) est activée en Task 4 ; cette task fait les `git mv` et termine par un commit. Le typecheck de validation est en Task 4 (après config).

- [ ] **Step 1: Déplacer les fichiers (git mv pour préserver l'historique)**

```bash
cd /home/matthieu/dev_malio/Lesstime/frontend
mkdir -p shared/stores
git mv composables/useApi.ts shared/composables/useApi.ts
git mv stores/auth.ts shared/stores/auth.ts
git mv stores/ui.ts shared/stores/ui.ts
```

- [ ] **Step 2: Vérifier qu'aucun import par CHEMIN ne pointe vers les anciens emplacements**

Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "composables/useApi\|stores/auth\|stores/ui" --include=*.ts --include=*.vue . | grep -v node_modules | grep -v "shared/"`
Expected: aucun résultat (tout passe par auto-import). Si un import explicite existe (ex. `from '~/composables/useApi'`), le corriger en `from '~/shared/composables/useApi'` ou retirer l'import (auto-import). Noter chaque correction.

> `layouts/default.vue` importe actuellement `useAppVersion` depuis `~/composables/useAppVersion` (NON déplacé) — ne pas y toucher ici.

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "refactor(front) : move useApi and shared stores (auth, ui) to shared/"
```

---

### Task 4: Frontend — `nuxt.config.ts` (srcDir, dossiers `app/`, scan des layers, auto-imports)

**Files:**
- Modify: `frontend/nuxt.config.ts`
- Create: `frontend/modules/.gitkeep` (dossier vide prêt pour le scan)
- Move: `frontend/layouts/` → `frontend/app/layouts/` (default.vue, auth.vue)
- Move: `frontend/middleware/` → `frontend/app/middleware/` (auth.global.ts, admin.ts, employee.ts)

**Interfaces:**
- Produces : structure `app/{layouts,middleware}`, `modules/` scannable, `shared/*` auto-importé.

- [ ] **Step 1: Déplacer layouts et middleware sous `app/`**

```bash
cd /home/matthieu/dev_malio/Lesstime/frontend
mkdir -p app modules
git mv layouts app/layouts
git mv middleware app/middleware
touch modules/.gitkeep
git add modules/.gitkeep
```

- [ ] **Step 2: Réécrire `nuxt.config.ts`**

Remplace INTÉGRALEMENT `frontend/nuxt.config.ts` par (conserve `vite`/`toast` existants — repris depuis la version actuelle) :

```ts
import { existsSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'

const modulesDir = resolve(__dirname, 'modules')
const moduleDirs = existsSync(modulesDir)
    ? readdirSync(modulesDir, { withFileTypes: true })
        .filter((d) => d.isDirectory())
        .map((d) => d.name)
    : []
const moduleLayers = moduleDirs.map((name) => `./modules/${name}`)
const moduleComposableDirs = moduleDirs
    .map((name) => `modules/${name}/composables`)
    .filter((path) => existsSync(resolve(__dirname, path)))
const moduleStoreDirs = moduleDirs
    .map((name) => `modules/${name}/stores`)
    .filter((path) => existsSync(resolve(__dirname, path)))

export default defineNuxtConfig({
    compatibilityDate: '2025-07-15',
    devtools: { enabled: false },
    ssr: false,
    srcDir: '.',
    css: ['~/assets/css/app.css', '~/assets/css/dark.css'],
    app: {
        baseURL: process.env.NODE_ENV === 'production'
            ? (process.env.NUXT_PUBLIC_APP_BASE || '/')
            : '/',
    },
    extends: ['@malio/layer-ui', ...moduleLayers],
    modules: [
        '@nuxtjs/tailwindcss',
        '@pinia/nuxt',
        'nuxt-toast',
        '@nuxtjs/i18n',
        '@nuxt/icon',
    ],
    dir: {
        layouts: 'app/layouts',
        middleware: 'app/middleware',
    },
    imports: {
        dirs: [
            'shared/composables',
            'shared/stores',
            'shared/utils',
            'composables',
            'stores',
            'utils',
            ...moduleComposableDirs,
            ...moduleStoreDirs,
        ],
    },
    pinia: {
        storesDirs: ['shared/stores/**', 'stores/**', 'modules/*/stores/**'],
    },
    runtimeConfig: {
        public: {
            apiBase: process.env.NUXT_PUBLIC_API_BASE,
        },
    },
    devServer: {
        port: 3002,
    },
    components: [
        { path: '~/components', pathPrefix: false },
    ],
    // ⬇️ Reprendre VERBATIM les blocs `vite: {...}`, `toast: {...}`, `i18n: {...}`,
    //    `typescript: {...}`, `build: {...}` de l'ancien nuxt.config.ts (inchangés).
    typescript: { strict: true },
    build: { transpile: ['@vuepic/vue-datepicker'] },
})
```

> ⚠️ Les blocs `vite`, `toast`, `i18n` de l'ancienne config ne sont pas réécrits ici : **les recopier à l'identique** depuis la version d'origine (récupérable via `git show HEAD~1:frontend/nuxt.config.ts` après les déplacements). Le `i18n.langDir: 'locales'` reste résolu depuis `i18n/`.

- [ ] **Step 3: Typecheck complet (valide Tasks 2, 3 et 4)**

Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
Expected: 0 erreur. Pièges probables :
- Store non trouvé → vérifier `pinia.storesDirs` inclut bien `shared/stores/**`.
- Composable non auto-importé → vérifier `imports.dirs` inclut `shared/composables`.
- `~/composables/useApi` cassé → un import explicite a survécu (corriger comme Task 3 Step 2).

- [ ] **Step 4: Smoke test runtime — l'app boote et la nav existante fonctionne**

Run: `cd /home/matthieu/dev_malio/Lesstime && make dev-nuxt` (ou rebuild SPA selon le workflow). Ouvrir l'app, se connecter (`alice`/`alice`), vérifier que la sidebar **statique actuelle** s'affiche encore et que la navigation marche (le layout n'est pas encore dynamisé — c'est normal). Aucun écran blanc / erreur console bloquante.
Expected: app fonctionnelle, identique à avant (les déplacements sont transparents).

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat(front) : modular nuxt config with app/ shell dirs and modules/* layer auto-detection"
```

---

### Task 5: Frontend — middlewares (`auth.global.ts` étendu + `modules.global.ts`)

**Files:**
- Modify: `frontend/app/middleware/auth.global.ts` (charge sidebar + modules après login ; reset au logout)
- Create: `frontend/app/middleware/modules.global.ts` (redirige les routes désactivées)

**Interfaces:**
- Consumes : `useAuthStore()`, `useSidebar()`, `useModules()` (auto-importés).

- [ ] **Step 1: Étendre `auth.global.ts`**

Remplace INTÉGRALEMENT `frontend/app/middleware/auth.global.ts` par :

```ts
export default defineNuxtRouteMiddleware(async (to) => {
    const auth = useAuthStore()
    const isLogin = to.path === '/login'

    if (!auth.checked) {
        await auth.ensureSession()
    }

    if (!isLogin && !auth.isAuthenticated) {
        return navigateTo('/login')
    }

    if (isLogin && auth.isAuthenticated) {
        return navigateTo('/')
    }

    const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar()
    const { loaded: modulesLoaded, loadModules, resetModules } = useModules()

    if (auth.isAuthenticated) {
        await Promise.all([
            sidebarLoaded.value ? Promise.resolve() : loadSidebar(),
            modulesLoaded.value ? Promise.resolve() : loadModules(),
        ])
    } else {
        // Logout / session expirée : purge l'état partagé pour le prochain login.
        resetSidebar()
        resetModules()
    }
})
```

- [ ] **Step 2: Créer `modules.global.ts`**

`frontend/app/middleware/modules.global.ts` :

```ts
export default defineNuxtRouteMiddleware(async (to) => {
    const auth = useAuthStore()
    if (!auth.isAuthenticated) {
        return
    }

    const { loaded, loadSidebar, isRouteDisabled } = useSidebar()
    if (!loaded.value) {
        await loadSidebar()
    }

    if (isRouteDisabled(to.path)) {
        return navigateTo('/')
    }
})
```

> Ordre des middlewares globaux : Nuxt les exécute par ordre alphabétique de nom de fichier → `auth.global.ts` puis `modules.global.ts`. C'est l'ordre voulu (auth charge la sidebar avant que modules teste les routes désactivées).

- [ ] **Step 3: Typecheck**

Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
Expected: 0 erreur.

- [ ] **Step 4: Smoke test — chargement sidebar/modules + redirection**

Avec le dev server : se connecter (`alice`), ouvrir l'onglet Réseau → confirmer un `GET /api/sidebar` et `GET /api/modules` après login. Vérifier la redirection : ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'` (module inactif) et un `'to' => '/demo-disabled'`, recharger, confirmer que `/demo-disabled` apparaît dans `disabledRoutes` (réponse `/api/sidebar`) et qu'y naviguer redirige vers `/`. **Puis retirer l'item de démo** (ne pas committer ce stub).
Expected: appels présents, redirection effective.

- [ ] **Step 5: Commit**

```bash
git add frontend/app/middleware/auth.global.ts frontend/app/middleware/modules.global.ts
git commit -m "feat(front) : load sidebar/modules after login and redirect disabled routes"
```

---

### Task 6: Frontend — layout `default.vue` : sidebar dynamique + items conservés

**Files:**
- Modify: `frontend/app/layouts/default.vue`

**Interfaces:**
- Consumes : `useSidebar()` (sections dynamiques traduites), `useUiStore()`, `useAuthStore()`, `useI18n()`, + le reste de la logique existante (timer, mail, refData) conservée VERBATIM.

> Stratégie : on remplace le bloc statique des items **globaux** (Tableau de bord, Mes tâches, Projets, Suivi de temps, Absences équipe, Administration) par un rendu **dynamique** issu de `useSidebar()`. On **conserve** les `SidebarLink` des items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail + badge) et user-flag (Mes absences) tels quels. Tout le `<script setup>` non lié à la sidebar (timer, drawer, head, mail polling, refData) est conservé à l'identique.

- [ ] **Step 1: Réécrire le bloc `<nav>` et l'en-tête du `<script setup>` de `frontend/app/layouts/default.vue`**

Dans le `<template>`, remplace le contenu de `<nav class="flex-1 overflow-hidden" …>…</nav>` (lignes ~40-167 de l'original) par :

```vue
                <nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
                    <!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
                    <template v-for="(section, sIndex) in translatedSections" :key="section.label">
                        <p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
                            {{ section.label }}
                        </p>
                        <div v-else class="mx-2 my-3 border-t border-secondary-500" />
                        <SidebarLink
                            v-for="item in section.items"
                            :key="item.to"
                            :to="item.to"
                            :icon="item.icon"
                            :label="item.label"
                            :collapsed="sidebarIsCollapsed"
                            @click="ui.closeMobileSidebar()"
                        />

                        <!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
                        <template v-if="sIndex === 0">
                            <!-- Contextuel projet -->
                            <template v-if="currentProjectId">
                                <SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
                                <SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
                                <SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
                            </template>
                            <!-- Feature-flag : Documents -->
                            <SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
                            <!-- Feature-flag : Mail + badge -->
                            <div v-if="isMailVisible" class="relative">
                                <SidebarLink to="/mail" icon="mdi:email-outline" :label="$t('mail.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
                                <span
                                    v-if="mailStore.globalUnreadCount > 0"
                                    class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
                                    :class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
                                    :aria-label="`${mailStore.globalUnreadCount} messages non lus`"
                                >
                                    {{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
                                </span>
                            </div>
                            <!-- User-flag : Mes absences (isEmployee — non couvert par le gate rôle) -->
                            <SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
                        </template>
                    </template>
                </nav>
```

Dans le `<script setup lang="ts">`, **ajoute** en tête (après les `useXxxStore()` existants) :

```ts
const { t } = useI18n()
const { sections } = useSidebar()

const translatedSections = computed(() =>
    sections.value.map((section) => ({
        label: t(section.label),
        icon: section.icon,
        items: section.items.map((item) => ({
            label: t(item.label),
            to: item.to,
            icon: item.icon,
        })),
    })),
)
```

**Conserve** tout le reste du `<script setup>` (`isAdmin`, `isEmployee`, `isMailVisible`, `isDocumentsVisible`, `currentProjectId`, `sidebarIsCollapsed`, timer/drawer/head/mail/refData…) et le `<style scoped>` à l'identique. `isAdmin`/`isAbsenceSectionVisible` deviennent inutilisés pour la sidebar (l'admin est gated côté serveur) — si le typecheck signale une variable inutilisée, retirer `isAbsenceSectionVisible` ; garder `isAdmin` s'il sert ailleurs, sinon le retirer aussi.

- [ ] **Step 2: Typecheck**

Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
Expected: 0 erreur (corriger toute variable / tout import inutilisé signalé).

- [ ] **Step 3: Smoke test visuel — non-régression de navigation**

Dev server. Se connecter successivement :
- `alice` (ROLE_USER) : sidebar affiche Tableau de bord / Mes tâches / Projets / Suivi de temps (dynamiques), + Documents/Mail si visibles, + Mes absences si employé ; **PAS** de section Administration ni Absences équipe.
- `admin` (ROLE_ADMIN) : en plus, section **Administration** avec Absences équipe + Administration.
- Entrer dans un projet (`/projects/<id>`) : Kanban/Groupes/Archives apparaissent (contextuel conservé).
Expected: tous les liens d'avant atteignables ; gating admin respecté. Noter tout délta visuel (ordre) pour validation PO (cf. décision 3).

- [ ] **Step 4: Commit**

```bash
git add frontend/app/layouts/default.vue
git commit -m "feat(front) : render dynamic sidebar from /api/sidebar in default layout"
```

---

### Task 7: Frontend — clés i18n `sidebar.*` + vérification bout-en-bout

**Files:**
- Modify: `frontend/i18n/locales/fr.json` (ajouter le namespace `sidebar`)

**Interfaces:**
- Consumes : les labels renvoyés par `/api/sidebar` (`sidebar.general.*`, `sidebar.admin.*`) traduits par `t()` dans `translatedSections`.

- [ ] **Step 1: Repérer la structure du fichier i18n**

Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && head -20 i18n/locales/fr.json`
Objectif : connaître l'indentation et confirmer que c'est un objet JSON imbriqué (ajouter une clé racine `sidebar`).

- [ ] **Step 2: Ajouter le namespace `sidebar`**

Ajoute (à la racine de l'objet JSON, en respectant l'indentation existante) :

```json
  "sidebar": {
    "general": {
      "section": "Gestion de projet",
      "dashboard": "Tableau de bord",
      "myTasks": "Mes tâches",
      "projects": "Projets",
      "timeTracking": "Suivi de temps"
    },
    "admin": {
      "section": "Administration",
      "teamAbsences": "Absences équipe",
      "administration": "Administration"
    }
  }
```

> Les libellés reprennent ceux du layout actuel. `sidebar.general.section` = « Gestion de projet » (regroupe désormais le Tableau de bord — délta cosmétique acté, décision 3).

- [ ] **Step 3: Typecheck + smoke i18n**

Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
Dev server : confirmer que les en-têtes/labels de sidebar s'affichent **traduits** (pas les clés brutes `sidebar.general.*`).
Expected: libellés FR corrects.

- [ ] **Step 4: Vérification bout-en-bout de l'activation/désactivation (AC)**

Test manuel documenté (aucun module réel en 0.2) :
1. Ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'`, `'to' => '/projects'` (route existante) dans une section visible.
2. `config/modules.php` reste vide (module `demo` inactif) → `GET /api/sidebar` doit lister `/projects` dans `disabledRoutes` et masquer l'item ; naviguer vers `/projects` doit rediriger vers `/`.
3. Ajouter une classe `DemoModule implements ModuleInterface { id()='demo' … }` + `config/modules.php` = `[DemoModule::class]` → l'item réapparaît, `/projects` n'est plus dans `disabledRoutes`, la navigation fonctionne.
4. **Tout retirer** (item démo + DemoModule + entrée modules.php). Confirmer l'état initial.
Documenter le résultat dans le message de fin. **Ne rien committer de ce stub.**

- [ ] **Step 5: Suite back + cs-fixer (non-régression globale) + commit**

Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: vert (inchangé vs Task 1).
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` → 0 erreur.
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(front) : add sidebar i18n labels"
```

---

## Acceptance check (après toutes les tasks)

- [ ] `frontend/app/{layouts,middleware}`, `frontend/shared/{composables,stores,types}`, `frontend/modules/` (vide) en place ; `nuxt.config.ts` scanne `modules/*/`.
- [ ] Sidebar **dynamique** alimentée par `/api/sidebar` pour la nav globale ; gate ROLE_ADMIN effectif (admin-only invisible pour `alice`).
- [ ] Route d'un module désactivé → **redirigée** vers `/` (vérifié via le stub démo).
- [ ] **Aucune page métier déplacée** ; `frontend/pages/` intact ; tous les liens actuels atteignables.
- [ ] `npx nuxt typecheck` = 0 erreur ; suite PHPUnit verte ; aucune migration BDD.
- [ ] Délta cosmétique d'ordre de sidebar présenté au PO pour validation.

## Notes pour le ticket suivant (1.1 — Module Core)

Le 1.1 migrera `User`/Auth dans `src/Module/Core/`, re-pointera `resolve_target_entities` vers `Module\Core\User`, déclarera `CoreModule` (REQUIRED) dans `config/modules.php`, et créera le premier vrai layer front `frontend/modules/core/` (login, profile, admin users) — c'est là que le scan de layers et `useModules`/`useSidebar` prennent tout leur sens (premier item de sidebar avec une clé `module` réelle).
