Aller au contenu

SPÉCIFICATION DES INTERFACES & NAVIGATION (SFDI)

ActuaryLab v2.0 — Plateforme Actuarielle Cabinet BFEV

Version 1.0 — Mars 2026 Document complémentaire au SFD_ActuaryLab_v2_0.md Classification : Document de référence projet — Confidentiel BFEV


OBJET DU DOCUMENT

Ce document spécifie, pour chaque écran d'ActuaryLab v2.0 : - La structure de navigation (chemins entrants/sortants, breadcrumb, sidebar active) - Les états de l'interface (vide, chargement, succès, erreur, accès refusé) - Les interactions élémentaires (champs, boutons, filtres, tri) - Les comportements HTMX (attributs exacts : hx-get, hx-post, hx-target, hx-swap, hx-trigger, hx-indicator) - Les comportements JavaScript (validations inline, toggles, calculs dynamiques) - Les modales et confirmations (contenu, boutons, comportement post-action) - Les toasts et alertes (durée, position, contenu) - Les transitions entre pages (redirections, pushstate, conditions)


CONVENTIONS TECHNIQUES

Notation HTMX

hx-get="<url>"           → Requête GET HTMX
hx-post="<url>"          → Requête POST HTMX
hx-target="#<id>"        → Élément cible du swap
hx-swap="<stratégie>"    → innerHTML / outerHTML / beforeend / none
hx-trigger="<event>"     → Événement déclencheur
hx-indicator="#<id>"     → Spinner affiché pendant la requête
hx-push-url="true/false" → Met à jour l'URL du navigateur
hx-confirm="<message>"   → Dialog de confirmation natif
hx-vals='{"k":"v"}'     → Valeurs supplémentaires à envoyer
hx-include="#<id>"       → Inclure d'autres champs dans la requête

Notation des états

[ÉTAT: VIDE]       → Aucune donnée à afficher
[ÉTAT: CHARGEMENT] → Requête en cours
[ÉTAT: SUCCÈS]     → Action réussie
[ÉTAT: ERREUR]     → Erreur serveur ou validation
[ÉTAT: INTERDIT]   → Accès refusé (403)
[ÉTAT: INACTIF]    → Élément désactivé (disabled)

Notation des animations

[ANIM: fade-in]    → Apparition progressive (150ms)
[ANIM: slide-down] → Glissement vers le bas
[ANIM: pulse]      → Pulsation (spinner)
[ANIM: flash]      → Clignotement bref sur mise à jour

Durées standard

Toast succès      : 4 000 ms puis disparaît (slide-up)
Toast erreur      : persistant jusqu'à clic ✕
Polling Celery    : toutes les 3 000 ms (tâche calcul)
Polling dashboard : toutes les 15 000 ms (file d'attente)
Debounce search   : 350 ms après dernière frappe
Transition page   : 150 ms fade

TABLE DES MATIÈRES

PARTIE A — STRUCTURE GLOBALE - A.1 Layout principal (sidebar + topbar) - A.2 Sidebar — navigation et états actifs - A.3 Topbar — notifications et menu utilisateur - A.4 Comportements globaux (session, 403, 404, 500) - A.5 Carte des flux de navigation (synthèse)

PARTIE B — AUTHENTIFICATION (Écrans 1.x) - B.1 Connexion (1.1) - B.2 Vérification 2FA (1.2) - B.3 Réinitialisation mot de passe (1.3) - B.4 Activation par invitation (1.4) - B.5 Profil utilisateur (1.5)

PARTIE C — ADMINISTRATION (Écrans 2.x) - C.1 Tableau de bord admin (2.1) - C.2 Liste des collaborateurs (2.2) - C.3 Invitation collaborateur (2.3) - C.4 Journal d'audit (2.4)

PARTIE D — CLIENTS (Écrans 3.x) - D.1 Liste des clients (3.1) - D.2 Création / édition client (3.2) - D.3 Espace client — onglets (3.3 à 3.6)

PARTIE E — DOSSIERS (Écrans 4.x) - E.1 Création d'un dossier (4.1) - E.2 Fiche dossier (4.2) - E.3 Changements de statut (4.3)

PARTIE F — IMPORT DONNÉES (Écrans 5.x) - F.1 Étape 1 — Upload (5.1) - F.2 Étape 2 — Mapping (5.2) - F.3 Étape 3 — Validation (5.3) - F.4 Étape 4 — Confirmation (5.4) - F.5 Historique et comparaison (5.5–5.6)

PARTIE G — ÉVALUATIONS — TRONC COMMUN (Écrans 6.x) - G.1 Création d'une évaluation (6.1) - G.2 Sélection du jeu de données (6.3)

PARTIE H — ÉVALUATION IFC (Écrans 7.x) - H.1 Paramétrage IFC (7.1) - H.2 Calcul — lancement et suivi (7.4) - H.3 Résultats — synthèse (7.5) - H.4 Résultats — détail, projections, sensibilité (7.6–7.8)

PARTIE I — ÉVALUATION ÉPARGNE SALARIALE (Écrans 8.x) - I.1 Paramétrage épargne (8.1–8.5) - I.2 Calcul et résultats (8.6–8.8)

PARTIE J — SCÉNARIOS (Écrans 9.x) - J.1 Bibliothèque et création (9.1–9.3) - J.2 Comparaison de scénarios (9.4)

PARTIE K — RAPPORTS (Écrans 10.x) - K.1 Génération et versionnement (10.1–10.3) - K.2 Export Excel (10.4)

PARTIE L — DASHBOARD & NOTIFICATIONS (Écrans 11.x) - L.1 Tableau de bord BFEV (11.1) - L.2 Centre de notifications (11.3)

PARTIE M — RÉFÉRENTIELS (Écrans 12.x) - M.1 Tables de mortalité (12.1) - M.2 Grilles IFC (12.2)


PARTIE A — STRUCTURE GLOBALE


A.1 Layout principal

Structure HTML de base

<body>
  <div id="app-layout" class="flex h-screen bg-slate-100">

    <!-- SIDEBAR fixe, non scrollable -->
    <aside id="sidebar" class="w-64 bg-[#1e3a5f] flex-shrink-0 flex flex-col">
      ...
    </aside>

    <!-- ZONE PRINCIPALE scrollable -->
    <div id="main-wrapper" class="flex-1 flex flex-col overflow-hidden">

      <!-- TOPBAR fixe -->
      <header id="topbar" class="h-16 bg-white border-b border-slate-200 flex-shrink-0">
        ...
      </header>

      <!-- CONTENU scrollable -->
      <main id="main-content" class="flex-1 overflow-y-auto p-6">
        <!-- Breadcrumb -->
        <nav id="breadcrumb" class="mb-4">...</nav>
        <!-- Contenu de la page -->
        <div id="page-content">
          <!-- Cible principale des swaps HTMX -->
        </div>
      </main>

    </div>
  </div>

  <!-- Toast container — toujours visible, position fixe -->
  <div id="toast-container"
       class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"
       aria-live="polite">
  </div>

  <!-- Modal overlay -->
  <div id="modal-overlay" class="hidden fixed inset-0 z-40 bg-black/50">
    <div id="modal-content" class="..."></div>
  </div>

</body>

Règles du layout

Règle Description
NAV-LAY-001 La sidebar ne scroll pas. Le contenu principal scroll indépendamment
NAV-LAY-002 La topbar reste visible en permanence lors du scroll
NAV-LAY-003 Le #toast-container est toujours en position fixed au-dessus de tout
NAV-LAY-004 Les modales utilisent #modal-overlay. Un seul modal peut être ouvert à la fois
NAV-LAY-005 Les pages sans authentification (login, reset, invitation) n'affichent pas sidebar ni topbar

A.2 Sidebar — navigation et états actifs

Structure et liens

Sidebar (#1e3a5f, texte blanc)
├── [LOGO] ActuaryLab + BFEV
│   → Cliquable → /dashboard/
├── ── Navigation principale ──
├── 🏠  Tableau de bord        → /dashboard/
├── 🏢  Clients                → /clients/
├── 📋  Missions               → /missions/
├── 📊  Données de référence   → /referentiels/     [AD, AC uniquement]
├── 📖  Journal d'audit        → /admin/audit/      [AD uniquement]
├── ── Administration ──        [AD uniquement]
├── 👥  Collaborateurs         → /admin/collaborateurs/
├── ── Espace personnel ──
├── 👤  Mon profil             → /profil/
└── 🚪  Se déconnecter         → POST /auth/deconnexion/

Comportement de l'item actif

Item actif :
  background    : rgba(255,255,255,0.15)
  border-left   : 3px solid #c17817   (amber)
  font-weight   : 600
  text-color    : white

Item inactif :
  background    : transparent
  text-color    : rgba(255,255,255,0.75)
  hover         : rgba(255,255,255,0.10) — transition 150ms

Item désactivé (rôle insuffisant) :
  display: none  (non affiché, pas seulement grisé)

Détermination de l'item actif

# Logique Django template — context processor
def sidebar_active(request):
    path = request.path
    return {
        'nav_dashboard':    path.startswith('/dashboard/'),
        'nav_clients':      path.startswith('/clients/'),
        'nav_missions':     path.startswith('/missions/'),
        'nav_referentiels': path.startswith('/referentiels/'),
        'nav_audit':        path.startswith('/admin/audit/'),
        'nav_collaborateurs': path.startswith('/admin/collaborateurs/'),
        'nav_profil':       path.startswith('/profil/'),
    }

Règles

Règle Description
NAV-SID-001 Un seul item peut être actif simultanément
NAV-SID-002 Les items invisibles selon le rôle sont retirés du DOM (pas seulement cachés)
NAV-SID-003 La déconnexion est un POST CSRF-protégé, pas un simple lien
NAV-SID-004 La sidebar ne se réduit pas (pas de mode collapsed en desktop)

A.3 Topbar — notifications et menu utilisateur

Structure

Topbar (hauteur 64px, bg-white, border-bottom)
├── [Gauche]  Breadcrumb contextuel (voir A.2 Breadcrumb)
└── [Droite]
    ├── 🔔 Bouton notifications
    │   └── [BADGE rouge] compteur non lus (masqué si 0)
    │   └── Clic → ouvre panneau latéral #notif-panel (slide-in depuis la droite)
    └── [AVATAR initiales + NOM]
        └── Clic → dropdown menu
            ├── 👤 Mon profil         → /profil/
            ├── 🔑 Changer mon MDP    → /profil/#securite
            └── 🚪 Se déconnecter     → POST /auth/deconnexion/

Comportement notifications (HTMX)

<!-- Badge compteur — mis à jour toutes les 60s -->
<span id="notif-badge"
      hx-get="/notifications/compteur/"
      hx-trigger="every 60s"
      hx-swap="outerHTML">
  3
</span>

<!-- Bouton d'ouverture du panneau -->
<button hx-get="/notifications/panneau/"
        hx-target="#notif-panel"
        hx-swap="innerHTML"
        hx-trigger="click"
        @click="panelOpen = true">
  🔔
</button>

<!-- Panneau latéral (slide depuis droite) -->
<div id="notif-panel"
     class="fixed right-0 top-0 h-full w-96 bg-white shadow-xl z-30
            transform transition-transform duration-300"
     :class="panelOpen ? 'translate-x-0' : 'translate-x-full'">
</div>

Comportement dropdown menu utilisateur

Clic avatar → affiche dropdown
Clic extérieur → ferme dropdown
Escape → ferme dropdown
Focus piégé dans dropdown si ouvert
Généré dynamiquement selon l'URL courante.

Exemples :
  /dashboard/                      → Accueil
  /clients/                        → Clients
  /clients/42/                     → Clients > TotalEnergies Gabon
  /clients/42/                     → Clients > TotalEnergies Gabon
  /missions/17/                    → Clients > TotalEnergies > IFC 2025
  /evaluations/8/resultats/        → Clients > TotalEnergies > IFC 2025 > Évaluation Scénario Base

Chaque segment est cliquable sauf le dernier (page courante).
Séparateur : › (chevron)

A.4 Comportements globaux

Session expirée

Déclencheur : toute requête HTMX ou navigation avec session expirée

Comportement Django :
  → Réponse 302 vers /auth/connexion/?next=<url_courante>

Comportement HTMX :
  → Intercepté par htmx:responseError ou header HX-Redirect
  → window.location.href = '/auth/connexion/?next=' + encodeURIComponent(window.location.pathname)

Message affiché sur /auth/connexion/ :
  [ALERT INFO] « Votre session a expiré. Veuillez vous reconnecter. »

Accès refusé (403)

Déclencheur : URL accessible par certains rôles seulement

Page 403 :
  Titre    : « Accès refusé »
  Message  : « Vous n'avez pas les droits nécessaires pour accéder à cette page. »
  Bouton   : [BOUTON AMBER] Retour au tableau de bord → /dashboard/
  (Pas de sidebar, layout centré)

Page introuvable (404)

Page 404 :
  Titre    : « Page introuvable »
  Message  : « La page que vous cherchez n'existe pas ou a été déplacée. »
  Bouton   : [BOUTON AMBER] Retour au tableau de bord → /dashboard/

Erreur serveur (500)

Page 500 :
  Titre    : « Une erreur est survenue »
  Message  : « Une erreur interne s'est produite. L'équipe technique a été notifiée. »
  Bouton   : [BOUTON AMBER] Retour au tableau de bord → /dashboard/

Toast système

// Fonction globale — appelable depuis n'importe quel template
function showToast(message, type = 'success', duration = 4000) {
  // type : 'success' | 'error' | 'warning' | 'info'
  // Position : fixed bottom-right
  // Animation entrée : slide-up + fade-in (200ms)
  // Animation sortie : slide-up + fade-out (200ms)
  // Fermeture manuelle : bouton ✕
  // Auto-fermeture : après `duration` ms (sauf type='error' → persistant)
}

// Déclenchement depuis HTMX via header de réponse
// Django renvoie : HX-Trigger: {"showToast": {"message": "...", "type": "success"}}
// Écouté par : document.body.addEventListener('showToast', handler)

A.5 Carte des flux de navigation

[Non authentifié]
  /auth/connexion/
    ├── Succès (sans 2FA) ──────────────────────→ /dashboard/
    ├── Succès (avec 2FA) ──────────────────────→ /auth/2fa/
    │     └── Code valide ────────────────────→ /dashboard/
    └── Mot de passe oublié ────────────────────→ /auth/reset-password/
          └── Email envoyé → /auth/reset-password/confirm/?token=xxx
                └── Confirmé ───────────────────→ /auth/connexion/ [toast succès]

  /auth/invitation/confirmer/?token=xxx
    ├── Token valide → formulaire activation
    │     └── Activé ─────────────────────────→ /dashboard/ [connexion auto]
    └── Token expiré → page erreur + lien demande

[Authentifié — toutes les pages suivantes nécessitent une session active]

/dashboard/
  ├── Carte "Dossiers en cours" ──────────────→ /missions/?statut=en_cours
  ├── Carte "Clients actifs" ─────────────────→ /clients/
  ├── Ligne dossier actif ────────────────────→ /missions/<id>/
  └── [LIEN] Voir le journal ────────────────→ /admin/audit/   [AD seulement]

/clients/
  ├── [+ Nouveau] ────────────────────────────→ /clients/nouveau/
  │     └── Créé ───────────────────────────→ /clients/<id>/   [toast succès]
  └── Ligne client ──────────────────────────→ /clients/<id>/
        ├── [TAB] Dossiers
        │     ├── [+ Dossier] ──────────────→ /missions/nouveau/?client=<id>
        │     └── [Ouvrir] ────────────────→ /missions/<id>/
        ├── [TAB] Données salariés
        │     ├── Ligne photo ─────────────→ /population/<id>/detail/
        │     └── [Comparer] ──────────────→ /population/comparer/?a=<id>&b=<id>
        └── [TAB] Scénarios
              └── [+ Créer] ───────────────→ modal création scénario

/missions/<id>/
  ├── [+ Évaluation] ─────────────────────────→ modal création évaluation
  │     └── Créée ─────────────────────────→ /evaluations/<id>/donnees/
  └── Ligne évaluation
        ├── [Voir résultats] ───────────────→ /evaluations/<id>/resultats/
        └── [Rapport] ─────────────────────→ /evaluations/<id>/rapport/

/evaluations/<id>/donnees/   (Phase 1)
  └── Jeu sélectionné ────────────────────────→ /evaluations/<id>/parametrage/

/evaluations/<id>/parametrage/   (Phase 2)
  └── Sauvegardé → lancement ───────────────→ /evaluations/<id>/calcul/

/evaluations/<id>/calcul/   (Phase 3)
  └── Calcul terminé ─────────────────────────→ /evaluations/<id>/resultats/

/evaluations/<id>/resultats/   (Phase 4)
  ├── [TAB] Synthèse
  ├── [TAB] Détail individuel
  ├── [TAB] Projections
  ├── [TAB] Sensibilité
  ├── [Export Excel] ────────────────────────→ téléchargement direct
  └── [Générer rapport] ──────────────────────→ /evaluations/<id>/rapport/

/population/import/etape-1/
  └── Suivant ────────────────────────────────→ /population/import/etape-2/
        └── Suivant ────────────────────────→ /population/import/etape-3/
              └── Suivant ─────────────────→ /population/import/etape-4/
                    └── Validé ─────────────→ /clients/<id>/ [onglet données]

PARTIE B — AUTHENTIFICATION


B.1 Connexion (Écran 1.1)

Entrantes :
  - Accès direct via navigateur
  - Redirection depuis session expirée (?next=<url>)
  - Redirection depuis /auth/deconnexion/
  - Lien « ← Retour » depuis /auth/2fa/ ou /auth/reset-password/

Sortantes :
  - Connexion réussie (sans 2FA) → /dashboard/ (ou ?next si présent)
  - Connexion réussie (avec 2FA) → /auth/2fa/
  - Lien « Mot de passe oublié » → /auth/reset-password/

États de la page

[ÉTAT: INITIAL]
  Formulaire vide. Bouton « Se connecter » actif.
  Si ?next présent : [ALERT INFO] « Veuillez vous connecter pour accéder à cette page. »

[ÉTAT: CHARGEMENT]
  POST soumis.
  Bouton « Se connecter » : disabled + [SPINNER] + texte « Connexion... »
  Champs : disabled
  Durée : pendant la requête serveur (~200-500ms)

[ÉTAT: ERREUR identifiants]
  [ALERT ROUGE] sous le formulaire : message RE-AUTH-001
  Champ mot de passe : vidé, refocus automatique
  Compteur de tentatives restantes affiché si ≥ 3 tentatives

[ÉTAT: COMPTE VERROUILLÉ]
  [ALERT ROUGE] message RE-AUTH-002
  Bouton désactivé
  Minuteur affiché : « Réessayez dans 28:43 » — décompte en JS côté client

[ÉTAT: COMPTE DÉSACTIVÉ]
  [ALERT ROUGE] message RE-AUTH-003
  Bouton désactivé

Comportements détaillés

// Validation côté client avant soumission
document.querySelector('#login-form').addEventListener('submit', function(e) {
  const email   = document.querySelector('#id_email').value.trim();
  const mdp     = document.querySelector('#id_password').value;
  let valid = true;

  if (!email) {
    showFieldError('#id_email', 'Ce champ est obligatoire.');
    valid = false;
  } else if (!isValidEmail(email)) {
    showFieldError('#id_email', 'Format d\'e-mail invalide.');
    valid = false;
  }

  if (!mdp) {
    showFieldError('#id_password', 'Ce champ est obligatoire.');
    valid = false;
  }

  if (!valid) e.preventDefault();
});

// Toggle affichage mot de passe
document.querySelector('#toggle-password').addEventListener('click', function() {
  const input = document.querySelector('#id_password');
  input.type  = input.type === 'password' ? 'text' : 'password';
  this.textContent = input.type === 'password' ? '👁' : '🙈';
});

Soumission du formulaire (POST classique — pas HTMX)

POST /auth/connexion/
  Content-Type: application/x-www-form-urlencoded
  Body: email=...&password=...&remember_me=on&csrfmiddlewaretoken=...

Réponses serveur :
  302 /dashboard/          → connexion réussie sans 2FA
  302 /auth/2fa/           → connexion réussie avec 2FA activé
  200 (même page)          → erreur, formulaire avec messages Django

B.2 Vérification 2FA (Écran 1.2)

Entrantes :
  - Redirection depuis /auth/connexion/ si 2FA activé

Sortantes :
  - Code valide → /dashboard/ (ou ?next si présent)
  - [Retour] → /auth/connexion/ (invalide la session temporaire)
  - 3 tentatives échouées → /auth/connexion/ + [ALERT] « Trop de tentatives. »

États

[ÉTAT: INITIAL]
  6 champs vides. Bouton « Vérifier » disabled.
  Focus automatique sur le 1er champ.

[ÉTAT: SAISIE EN COURS]
  Bouton « Vérifier » : activé dès que les 6 chiffres sont remplis.

[ÉTAT: CHARGEMENT]
  POST soumis automatiquement à la saisie du 6e chiffre.
  Tous les champs : disabled + [SPINNER] sur le bouton.

[ÉTAT: ERREUR code]
  [ALERT ROUGE] « Code invalide. Réessayez. »
  Tous les champs : vidés, focus sur champ 1.
  Compteur : « X tentatives restantes »

[ÉTAT: CODE RÉCUPÉRATION]
  Affichage d'un INPUT texte unique (code 8 caractères).
  [BOUTON] « Utiliser ce code ».

Comportements JavaScript

const inputs = document.querySelectorAll('.otp-input');

inputs.forEach((input, index) => {
  input.addEventListener('input', function() {
    // Accepter chiffres uniquement
    this.value = this.value.replace(/\D/g, '').slice(0, 1);

    // Avancer au champ suivant
    if (this.value && index < inputs.length - 1) {
      inputs[index + 1].focus();
    }

    // Soumettre si tous les champs remplis
    if ([...inputs].every(i => i.value)) {
      document.querySelector('#otp-form').submit();
    }
  });

  input.addEventListener('keydown', function(e) {
    // Retour arrière → champ précédent
    if (e.key === 'Backspace' && !this.value && index > 0) {
      inputs[index - 1].focus();
    }
    // Coller (Ctrl+V) → distribuer les chiffres
    if (e.key === 'v' && e.ctrlKey) {
      e.preventDefault();
      navigator.clipboard.readText().then(text => {
        const digits = text.replace(/\D/g, '').slice(0, 6).split('');
        digits.forEach((d, i) => { if (inputs[i]) inputs[i].value = d; });
        inputs[Math.min(digits.length, 5)].focus();
        if (digits.length === 6) document.querySelector('#otp-form').submit();
      });
    }
  });
});

B.3 Réinitialisation mot de passe (Écran 1.3)

Étape 1 — Demande

Navigation entrante : lien depuis /auth/connexion/
Navigation sortante :
  - Soumission → même page avec [ALERT INFO] (anti-énumération)
  - [Retour] → /auth/connexion/

État SUCCÈS (email envoyé ou non) :
  [ALERT INFO VERT/BLEU] :
  « Si un compte existe pour cette adresse, vous recevrez un e-mail
    dans quelques minutes. Pensez à vérifier vos spams. »
  Formulaire masqué après soumission réussie.
  [BOUTON] « Retour à la connexion » → /auth/connexion/

Étape 2 — Confirmation (token)

Navigation entrante : lien e-mail → /auth/reset-password/confirm/?token=<uuid>

[ÉTAT: TOKEN INVALIDE OU EXPIRÉ]
  [ALERT ROUGE]
  « Ce lien est invalide ou a expiré (durée maximale : 2 heures). »
  [BOUTON] « Demander un nouveau lien » → /auth/reset-password/
  Formulaire non affiché.

[ÉTAT: TOKEN VALIDE]
  Formulaire affiché. Indicateur de force en temps réel.

Indicateur de force du mot de passe (JS temps réel) :
  ░░░░  Trop court
  ██░░  Faible
  ███░  Moyen
  ████  Fort (toutes les conditions remplies)

  Conditions vérifiées en temps réel avec icône ✅/❌ :
  - Longueur ≥ 12 caractères
  - Au moins 1 majuscule
  - Au moins 1 chiffre
  - Au moins 1 caractère spécial

[ÉTAT: SUCCÈS]
  [TOAST VERT] « Mot de passe modifié avec succès. »
  Redirection automatique après 2 000 ms → /auth/connexion/

B.4 Activation par invitation (Écran 1.4)

Navigation entrante : lien e-mail → /auth/invitation/confirmer/?token=<uuid>

[ÉTAT: TOKEN EXPIRÉ (> 72h)]
  [ALERT ROUGE] « Cette invitation a expiré. »
  [BOUTON] « Contacter l'administrateur »
  → mailto:admin@bfev.ga (ou page de contact)

[ÉTAT: DÉJÀ ACTIVÉ]
  [ALERT INFO] « Ce compte a déjà été activé. »
  [BOUTON AMBER] « Se connecter » → /auth/connexion/

[ÉTAT: FORMULAIRE ACTIF]
  Email pré-rempli et readonly (attribut disabled + hidden input pour la valeur).
  Rôle affiché en [BADGE] non modifiable.
  Même indicateur de force mot de passe que B.3.

[ÉTAT: SOUMISSION]
  Bouton disabled + spinner.
  Validation côté client identique à B.3.

[ÉTAT: SUCCÈS]
  Connexion automatique côté serveur (set_password + login()).
  Redirection → /dashboard/
  [TOAST VERT] « Bienvenue dans ActuaryLab, <Prénom> ! »

B.5 Profil utilisateur (Écran 1.5)

Entrante :
  - Menu utilisateur topbar → /profil/
  - Lien direct depuis sidebar

Sortante :
  - Aucune navigation automatique
  - Toutes les actions se font sur la même page (HTMX partials)

Comportements HTMX

<!-- Section Informations personnelles -->
<form hx-post="/profil/modifier/"
      hx-target="#profil-info-section"
      hx-swap="outerHTML"
      hx-indicator="#profil-spinner">

  <!-- Spinner affiché pendant la requête -->
  <div id="profil-spinner" class="htmx-indicator">
    [SPINNER]
  </div>
</form>

<!-- Réponse succès → Django renvoie le partial mis à jour
     + header HX-Trigger: {"showToast": {"message": "Profil mis à jour.", "type": "success"}} -->

<!-- Changer mot de passe → ouvre modal -->
<button hx-get="/profil/changer-mdp/"
        hx-target="#modal-content"
        hx-swap="innerHTML"
        onclick="document.getElementById('modal-overlay').classList.remove('hidden')">
  Changer mon mot de passe
</button>

<!-- Révoquer toutes les sessions -->
<button hx-post="/profil/revoquer-sessions/"
        hx-confirm="Vous serez déconnecté de tous vos appareils. Continuer ?"
        hx-target="#sessions-section"
        hx-swap="outerHTML">
  Déconnecter toutes les sessions
</button>
Contenu :
  [INPUT] Mot de passe actuel *
  [INPUT] Nouveau mot de passe *   + indicateur de force
  [INPUT] Confirmer *

  [BOUTON SECONDAIRE] Annuler → ferme modal
  [BOUTON AMBER] Confirmer

États post-soumission :
  Succès → ferme modal + [TOAST VERT] « Mot de passe modifié. »
  Erreur (MDP actuel incorrect) → [ALERT ROUGE] dans le modal
  Erreur (historique) → « Ce mot de passe a déjà été utilisé récemment. »

Section 2FA (profil)

Si 2FA NON activé :
  [BADGE GRIS] Non activée
  [BOUTON] Activer → GET /profil/2fa/activer/
    → Affiche QR code + code secret + 6 inputs OTP pour validation
    → Validation réussie → [TOAST VERT] « 2FA activée. »
    → Affiche les 10 codes de récupération (à copier)

Si 2FA ACTIVÉ :
  [BADGE VERT] Activée
  [BOUTON] Désactiver → hx-confirm + POST
  [BOUTON] Voir les codes de récupération → modal avec les codes restants

PARTIE C — ADMINISTRATION


C.1 Tableau de bord admin (Écran 2.1)

Polling HTMX — file d'attente Celery

<!-- Section file d'attente — mise à jour toutes les 15s -->
<div id="celery-queue-section"
     hx-get="/admin/celery-queue/"
     hx-trigger="every 15s"
     hx-swap="outerHTML"
     hx-indicator="#queue-spinner">
</div>

<!-- Si la file est vide → le bloc est masqué (réponse vide ou hidden) -->
<!-- Si des tâches existent → affichage du tableau avec spinner rotatif -->

Comportement des cartes compteurs

<!-- Chaque carte est cliquable (lien <a> wrappant la card) -->
<a href="/admin/collaborateurs/" class="card-compteur">
  <span class="nombre">8</span>
  <span class="label">Collaborateurs actifs</span>
</a>

<!-- Hover : légère élévation (shadow-md → shadow-lg, transition 150ms) -->
<!-- Cursor : pointer -->

Activité récente

<!-- Chargement initial via include Django -->
<!-- Pas de polling — rechargement à la navigation -->
<!-- [LIEN] Voir le journal → /admin/audit/ (navigation standard) -->

Délai restant — couleur dynamique

// Calculé côté serveur au rendu de la page
// Valeur : nombre de jours jusqu'à la date de livraison

function getBadgeClass(joursRestants) {
  if (joursRestants > 14) return 'badge-green';
  if (joursRestants >= 7) return 'badge-amber';
  return 'badge-red';
}

C.2 Liste des collaborateurs (Écran 2.2)

Recherche et filtres (HTMX live)

<form id="collab-filters">
  <!-- Recherche avec debounce 350ms -->
  <input name="q"
         hx-get="/admin/collaborateurs/"
         hx-trigger="keyup changed delay:350ms"
         hx-target="#collab-table-body"
         hx-swap="innerHTML"
         hx-include="#collab-filters"
         hx-push-url="true"
         placeholder="Rechercher...">

  <!-- Filtres SELECT → déclenchés au changement -->
  <select name="role"
          hx-get="/admin/collaborateurs/"
          hx-trigger="change"
          hx-target="#collab-table-body"
          hx-swap="innerHTML"
          hx-include="#collab-filters">
    <option value="">Tous les rôles</option>
    <option value="AD">Administrateur</option>
    <option value="AC">Actuaire responsable</option>
    <option value="CO">Consultant</option>
  </select>

  <select name="statut"
          hx-get="/admin/collaborateurs/"
          hx-trigger="change"
          hx-target="#collab-table-body"
          hx-swap="innerHTML"
          hx-include="#collab-filters">
    <option value="">Tous les statuts</option>
    <option value="actif">Actif</option>
    <option value="invite">Invité</option>
    <option value="inactif">Inactif</option>
  </select>
</form>

<!-- Corps du tableau — remplacé par HTMX -->
<tbody id="collab-table-body">
  <!-- Rendu par Django partial collaborateurs/_table_body.html -->
</tbody>
<!-- Bouton ⋮ par ligne -->
<div class="relative" x-data="{ open: false }">
  <button @click="open = !open" @click.outside="open = false"></button>

  <div x-show="open"
       x-transition
       class="absolute right-0 bg-white shadow-lg rounded z-10 w-48">

    <!-- Voir le profil -->
    <a href="/admin/collaborateurs/<id>/">Voir le profil</a>

    <!-- Modifier le rôle → modal HTMX -->
    <button hx-get="/admin/collaborateurs/<id>/modifier-role/"
            hx-target="#modal-content"
            hx-swap="innerHTML"
            @click="document.getElementById('modal-overlay').classList.remove('hidden'); open=false">
      Modifier le rôle
    </button>

    <!-- Renvoyer l'invitation (si statut = Invité) -->
    <button hx-post="/admin/collaborateurs/<id>/reinviter/"
            hx-confirm="Renvoyer l'invitation à <email> ?"
            hx-swap="none"
            @click="open=false">
      Renvoyer l'invitation
    </button>

    <!-- Désactiver (si actif, pas soi-même) -->
    <button hx-post="/admin/collaborateurs/<id>/desactiver/"
            hx-confirm="Désactiver le compte de <nom> ? Cette action révoque ses sessions actives."
            hx-target="#collab-row-<id>"
            hx-swap="outerHTML"
            class="text-red-600"
            @click="open=false">
      Désactiver le compte
    </button>

  </div>
</div>

État [ÉTAT: VIDE] (aucun résultat)

<tbody id="collab-table-body">
  <tr>
    <td colspan="4" class="text-center py-12 text-slate-400">
      <p>Aucun collaborateur trouvé.</p>
      <p>Modifiez vos filtres ou <a href="#" onclick="resetFilters()">réinitialisez</a>.</p>
    </td>
  </tr>
</tbody>

C.3 Invitation collaborateur (Écran 2.3)

Ouverture de la modal

<!-- Bouton dans la liste -->
<button hx-get="/admin/collaborateurs/inviter/"
        hx-target="#modal-content"
        hx-swap="innerHTML"
        onclick="document.getElementById('modal-overlay').classList.remove('hidden')">
  + Inviter
</button>

Comportement du formulaire

<form hx-post="/admin/collaborateurs/inviter/"
      hx-target="#modal-content"
      hx-swap="innerHTML"
      hx-indicator="#invite-spinner">

  <!-- Validation email en temps réel -->
  <input name="email"
         hx-get="/admin/collaborateurs/verifier-email/"
         hx-trigger="blur"
         hx-target="#email-feedback"
         hx-swap="innerHTML"
         hx-include="[name='email']">
  <span id="email-feedback"></span>
  <!-- Réponse serveur : "" si OK, "<span class='error'>Un compte existe déjà...</span>" si doublon -->

  <!-- ... autres champs ... -->

  <div id="invite-spinner" class="htmx-indicator">[SPINNER]</div>
</form>

États post-soumission

Succès :
  → Modal se ferme automatiquement
  → [TOAST VERT] « Invitation envoyée à <email>. »
  → Ligne ajoutée en tête de tableau avec [BADGE AMBER] « Invit. en attente »
     (via hx-swap="beforebegin" sur le tbody, ou rechargement du tableau)

Erreur validation serveur :
  → Modal reste ouverte
  → Erreurs affichées sous chaque champ concerné
  → Bouton réactivé

Fermeture manuelle (Annuler ou clic overlay) :
  → onclick="document.getElementById('modal-overlay').classList.add('hidden')"
  → Contenu du modal vidé

C.4 Journal d'audit (Écran 2.4)

Filtres et pagination

<form id="audit-filters">
  <!-- Plage de dates -->
  <input type="date" name="date_debut"
         hx-get="/admin/audit/"
         hx-trigger="change"
         hx-target="#audit-table"
         hx-swap="outerHTML"
         hx-include="#audit-filters"
         hx-push-url="true">

  <input type="date" name="date_fin" ...>

  <select name="utilisateur" ...>...</select>
  <select name="action" ...>...</select>
  <select name="module" ...>...</select>
</form>

<!-- Bouton filtre (soumission manuelle alternative) -->
<button hx-get="/admin/audit/"
        hx-target="#audit-table"
        hx-swap="outerHTML"
        hx-include="#audit-filters"
        hx-push-url="true">
  Filtrer
</button>

<!-- Bouton réinitialiser -->
<button onclick="resetAuditFilters()"
        hx-get="/admin/audit/"
        hx-target="#audit-table"
        hx-swap="outerHTML">
  Réinitialiser
</button>

Ligne détail expandable

<!-- Ligne principale -->
<tr class="cursor-pointer hover:bg-slate-50"
    hx-get="/admin/audit/<id>/detail/"
    hx-target="#detail-row-<id>"
    hx-swap="outerHTML"
    hx-trigger="click">
  <td>15:42:03</td>
  <td>Jean D.</td>
  <td>Calcul lancé</td>
  <td>IFC</td>
</tr>

<!-- Ligne détail (initialement vide, injectée par HTMX) -->
<tr id="detail-row-<id>"></tr>
<!-- Réponse HTMX : <tr id="detail-row-<id>" class="bg-slate-50"><td colspan="4">...</td></tr> -->
<!-- Second clic → referme (toggle via classe CSS) -->

Export CSV

<!-- Lien de téléchargement direct (pas HTMX) -->
<!-- Les paramètres de filtre courants sont transmis via query string -->
<a id="export-csv-btn"
   href="/admin/audit/export/?date_debut=...&date_fin=...&module=..."
   class="btn-secondary">
  ⬇ Export CSV
</a>

<!-- URL construite dynamiquement en JS à partir des filtres actifs -->
<script>
function updateExportUrl() {
  const params = new URLSearchParams(new FormData(document.getElementById('audit-filters')));
  document.getElementById('export-csv-btn').href = '/admin/audit/export/?' + params.toString();
}
document.getElementById('audit-filters').addEventListener('change', updateExportUrl);
</script>

PARTIE D — CLIENTS


D.1 Liste des clients (Écran 3.1)

Filtres live et recherche

<!-- Même pattern que C.2 — debounce 350ms sur la recherche -->
<input name="q"
       hx-get="/clients/"
       hx-trigger="keyup changed delay:350ms"
       hx-target="#clients-list"
       hx-swap="innerHTML"
       hx-include="#client-filters"
       hx-push-url="true">

<select name="secteur" hx-get="/clients/" hx-trigger="change"
        hx-target="#clients-list" hx-swap="innerHTML"
        hx-include="#client-filters" hx-push-url="true">
  ...
</select>

Clic sur une ligne client

<!-- Toute la ligne est cliquable -->
<tr class="cursor-pointer hover:bg-slate-50"
    onclick="window.location='/clients/<id>/'">
  ...
</tr>
<!-- Cursor pointer sur la ligne. Navigation standard (pas HTMX). -->

État vide (aucun client)

[ÉTAT: VIDE — aucun client affecté]
  Affiché pour [AC] ou [CO] sans clients affectés :
  Icône bâtiment 🏢
  « Aucun client ne vous a été affecté. »
  « Contactez l'administrateur pour obtenir des accès. »
  (Pas de bouton « Nouveau » pour ces rôles)

D.2 Création / Édition client (Écran 3.2)

Entrante :
  - [+ Nouveau] depuis /clients/ → /clients/nouveau/
  - [Modifier] depuis /clients/<id>/ → /clients/<id>/modifier/

Sortante :
  - Succès création → /clients/<id>/          [toast « Client créé. »]
  - Succès modification → /clients/<id>/      [toast « Client mis à jour. »]
  - [Annuler] → /clients/ (ou retour historique)

Auto-complétion du nom court

// Génération automatique du sigle depuis la raison sociale
document.querySelector('#id_raison_sociale').addEventListener('input', function() {
  const sigle = document.querySelector('#id_nom_court');
  if (!sigle.dataset.modified) {
    // Prendre les initiales ou les 10 premiers caractères
    sigle.value = this.value
      .split(' ')
      .filter(w => w.length > 2)
      .map(w => w[0].toUpperCase())
      .join('')
      .slice(0, 10);
  }
});

document.querySelector('#id_nom_court').addEventListener('input', function() {
  this.dataset.modified = 'true'; // L'utilisateur a saisi manuellement
});

Préremplissage du droit du travail selon pays

<select name="pays_siege" id="id_pays_siege"
        hx-get="/clients/droit-travail/"
        hx-trigger="change"
        hx-target="#id_droit_travail"
        hx-swap="outerHTML"
        hx-include="[name='pays_siege']">
  ...
</select>
<!-- Réponse : <select name="droit_travail" id="id_droit_travail">
                 <option value="gabon" selected>Gabon</option>...
               </select> -->

Validation et soumission

Soumission : POST standard (pas HTMX — redirection serveur après succès)

Erreurs serveur :
  → Formulaire réaffiché avec messages sous chaque champ
  → [ALERT ROUGE] en haut du formulaire :
    « Le formulaire contient des erreurs. Veuillez les corriger. »

Validation côté client :
  - Raison sociale : non vide
  - Secteur, pays, référentiel, droit du travail : sélection obligatoire
  - Actuaire responsable : sélection obligatoire

D.3 Espace client — onglets (Écrans 3.3 à 3.6)

Comportement des onglets (HTMX)

<!-- En-tête des onglets -->
<div class="tab-header flex border-b border-slate-200">
  <button class="tab-btn active"
          hx-get="/clients/<id>/onglet/dossiers/"
          hx-target="#tab-content"
          hx-swap="innerHTML"
          hx-push-url="false"
          onclick="setActiveTab(this)">
    Dossiers
  </button>
  <button class="tab-btn"
          hx-get="/clients/<id>/onglet/donnees/"
          hx-target="#tab-content"
          hx-swap="innerHTML"
          hx-push-url="false"
          onclick="setActiveTab(this)">
    Données salariés
  </button>
  <button class="tab-btn"
          hx-get="/clients/<id>/onglet/scenarios/"
          hx-target="#tab-content"
          hx-swap="innerHTML"
          onclick="setActiveTab(this)">
    Scénarios
  </button>
  <button class="tab-btn"
          hx-get="/clients/<id>/onglet/notes/"
          hx-target="#tab-content"
          hx-swap="innerHTML"
          onclick="setActiveTab(this)">
    Notes internes
  </button>
</div>

<!-- Zone de contenu de l'onglet -->
<div id="tab-content">
  <!-- Contenu initial : onglet Dossiers -->
</div>

<script>
function setActiveTab(el) {
  document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
  el.classList.add('active');
}
</script>

Onglet Dossiers — actions

<!-- Bouton + Dossier -->
<a href="/missions/nouveau/?client=<id>" class="btn-amber">+ Dossier</a>
<!-- Navigation standard — pas de modal (formulaire trop riche) -->

<!-- Bouton Ouvrir un dossier -->
<a href="/missions/<id>/" class="btn-secondary">Ouvrir le dossier</a>

Onglet Données salariés — sélection pour comparaison

<!-- Cases à cocher sur chaque ligne de photographie -->
<input type="checkbox" class="photo-select" value="<id>"
       onchange="updateCompareBtn()">

<!-- Bouton Comparer — activé uniquement si exactement 2 sélectionnées -->
<button id="btn-comparer"
        disabled
        onclick="navigateToCompare()">
  Comparer
</button>

<script>
function updateCompareBtn() {
  const selected = document.querySelectorAll('.photo-select:checked');
  const btn = document.getElementById('btn-comparer');
  btn.disabled = selected.length !== 2;
}

function navigateToCompare() {
  const ids = [...document.querySelectorAll('.photo-select:checked')].map(c => c.value);
  window.location = '/population/comparer/?a=' + ids[0] + '&b=' + ids[1];
}
</script>

Onglet Notes internes

<!-- Edition inline avec HTMX -->
<textarea id="notes-textarea" name="notes">
  {{ client.notes_internes }}
</textarea>

<button hx-post="/clients/<id>/notes/"
        hx-include="#notes-textarea"
        hx-target="#notes-feedback"
        hx-swap="innerHTML">
  Enregistrer
</button>

<span id="notes-feedback"></span>
<!-- Réponse succès : <span class="text-green-600">✅ Notes enregistrées.</span> -->
<!-- Disparaît après 3s via htmx:afterSettle + setTimeout -->

PARTIE E — DOSSIERS


E.1 Création d'un dossier (Écran 4.1)

Entrante :
  - [+ Dossier] depuis onglet Dossiers d'un client
    → /missions/nouveau/?client=<id>
  - [+ Dossier] depuis /missions/
    → /missions/nouveau/ (client à sélectionner dans le formulaire)

Sortante :
  - Succès → /missions/<id>/   [toast « Dossier créé. »]
  - [Annuler] → retour (history.back())

Comportements

<!-- Validation dates cohérentes -->
<input type="date" name="date_cloture" id="id_date_cloture">
<input type="date" name="date_livraison" id="id_date_livraison">

<script>
document.getElementById('id_date_livraison').addEventListener('change', function() {
  const cloture  = new Date(document.getElementById('id_date_cloture').value);
  const livraison = new Date(this.value);
  if (livraison <= cloture) {
    showFieldError('#id_date_livraison',
      'La date de livraison doit être postérieure à la date de clôture.');
    this.value = '';
  }
});
</script>

<!-- Pré-remplissage de l'actuaire depuis le client -->
<!-- Si ?client=<id> présent → Django pré-remplit l'actuaire responsable du client -->
<!-- L'utilisateur peut modifier si [AD] -->

E.2 Fiche dossier (Écran 4.2)

Barre de progression

// Calculée côté Django, rendue en template
// Phases : donnees(25%) → parametrage(50%) → calcul(75%) → resultats(100%)
// La largeur est un attribut inline : style="width: 65%"

const phaseProgress = {
  'donnees':     25,
  'parametrage': 50,
  'calcul':      75,
  'resultats':  100,
};
// La phase la plus avancée parmi toutes les évaluations du dossier détermine le %

Création d'une évaluation (modal)

<!-- Bouton + Évaluation -->
<button hx-get="/evaluations/nouvelle/?dossier=<id>"
        hx-target="#modal-content"
        hx-swap="innerHTML"
        onclick="document.getElementById('modal-overlay').classList.remove('hidden')">
  + Évaluation
</button>

<!-- Soumission de la modal -->
<!-- hx-post="/evaluations/nouvelle/" -->
<!-- Succès → header HX-Redirect: /evaluations/<new_id>/donnees/ -->
<!-- → window.location = url (intercepté par htmx:beforeSwap) -->

Journal du dossier

<!-- Chargé au rendu initial, pas de polling -->
<!-- Les entrées sont ajoutées côté serveur à chaque action -->
<!-- L'utilisateur doit rafraîchir la page pour voir les nouvelles entrées
     → ou polling facultatif toutes les 30s si calcul en cours -->
<div id="journal-dossier"
     hx-get="/missions/<id>/journal/"
     hx-trigger="load, every 30s [calculEnCours]"
     hx-swap="innerHTML">
</div>

E.3 Changements de statut (Écran 4.3)

Bouton de changement de statut

<!-- Affiché selon le statut courant et le rôle -->

<!-- En cours → En validation (bouton visible pour [AC] et [AD]) -->
<button hx-post="/missions/<id>/statut/"
        hx-vals='{"statut": "en_validation"}'
        hx-confirm="Passer ce dossier en validation ? Les résultats seront verrouillés."
        hx-target="#statut-badge"
        hx-swap="outerHTML">
  Envoyer en validation
</button>

<!-- En validation → Livré -->
<button hx-post="/missions/<id>/statut/"
        hx-vals='{"statut": "livre"}'
        hx-confirm="Marquer ce dossier comme Livré ? Cette action est définitive."
        hx-target="#statut-badge"
        hx-swap="outerHTML">
  Marquer comme livré
</button>

<!-- Livré → Archivé (confirmation avec saisie du nom) -->
<button onclick="openArchiveModal('<id>', '<titre>')">
  Archiver le dossier
</button>
<div id="archive-modal">
  <p>Pour confirmer l'archivage, saisissez le titre du dossier :</p>
  <p><strong>IFC — Exercice 2025</strong></p>

  <input type="text" id="archive-confirm-input"
         placeholder="Saisissez le titre exact..."
         oninput="checkArchiveConfirm()">

  <button id="archive-btn-confirm"
          disabled
          hx-post="/missions/<id>/archiver/"
          hx-confirm="">
    Archiver définitivement
  </button>
</div>

<script>
function checkArchiveConfirm() {
  const input = document.getElementById('archive-confirm-input').value;
  const titre = 'IFC — Exercice 2025'; // injecté par Django
  document.getElementById('archive-btn-confirm').disabled = (input !== titre);
}
</script>

PARTIE F — IMPORT DONNÉES SALARIÉS


F.1 Étape 1 — Upload (Écran 5.1)

Entrante :
  - [Import données] depuis onglet Données salariés d'un client
  - URL directe : /population/import/etape-1/?client=<id>
  - La session d'import est initialisée à ce moment (clé Django session : import_session_<client_id>)

Sortante :
  - [Suivant] → /population/import/etape-2/<session_id>/
  - [Annuler] → /clients/<id>/ (onglet données)
  - Session expirée (> 30 min) → page d'erreur + [BOUTON] Recommencer

Zone de drop

<div id="upload-zone"
     class="border-2 border-dashed border-slate-300 rounded-lg p-12 text-center
            hover:border-amber-500 transition-colors cursor-pointer"
     ondrop="handleDrop(event)"
     ondragover="handleDragOver(event)"
     ondragleave="handleDragLeave(event)"
     onclick="document.getElementById('file-input').click()">

  <input type="file" id="file-input" name="fichier"
         accept=".xlsx,.xls,.csv"
         class="hidden"
         onchange="handleFileSelected(this)">

  <!-- Icône + texte -->
  <p>📂 Glissez votre fichier ici ou <span class="text-amber-600 underline">parcourir</span></p>
  <p class="text-sm text-slate-400">Formats acceptés : .xlsx .xls .csv — Taille max : 10 Mo</p>
</div>

<!-- Aperçu du fichier sélectionné -->
<div id="file-preview" class="hidden mt-4">
  <span id="file-name"></span>
  <span id="file-size"></span>
  <button onclick="clearFile()"></button>
</div>

Comportements JavaScript

function handleFileSelected(input) {
  const file = input.files[0];
  if (!file) return;

  // Vérification taille
  if (file.size > 10 * 1024 * 1024) {
    showFieldError('#upload-zone', 'Fichier trop volumineux (max 10 Mo).');
    input.value = '';
    return;
  }

  // Vérification extension
  const ext = file.name.split('.').pop().toLowerCase();
  if (!['xlsx', 'xls', 'csv'].includes(ext)) {
    showFieldError('#upload-zone', 'Format non accepté. Utilisez .xlsx, .xls ou .csv.');
    input.value = '';
    return;
  }

  // Afficher l'aperçu
  document.getElementById('file-name').textContent = file.name;
  document.getElementById('file-size').textContent = formatSize(file.size);
  document.getElementById('file-preview').classList.remove('hidden');
  document.getElementById('upload-zone').classList.add('border-green-400');

  // Si Excel multi-feuilles → détecter via HTMX après upload partiel
  detectSheets(file);
}

function handleDragOver(e) {
  e.preventDefault();
  document.getElementById('upload-zone').classList.add('border-amber-500', 'bg-amber-50');
}
function handleDragLeave(e) {
  document.getElementById('upload-zone').classList.remove('border-amber-500', 'bg-amber-50');
}
function handleDrop(e) {
  e.preventDefault();
  handleDragLeave(e);
  const file = e.dataTransfer.files[0];
  document.getElementById('file-input').files = e.dataTransfer.files;
  handleFileSelected(document.getElementById('file-input'));
}

Détection des feuilles Excel (HTMX)

<!-- Après sélection du fichier, upload immédiat pour détection des feuilles -->
<form id="detect-sheets-form">
  <input type="file" name="fichier" id="file-input-hidden">
</form>

<!-- Déclenché par handleFileSelected() -->
<script>
function detectSheets(file) {
  const formData = new FormData();
  formData.append('fichier', file);
  formData.append('csrfmiddlewaretoken', getCsrfToken());

  fetch('/population/import/detecter-feuilles/', {
    method: 'POST',
    body: formData
  })
  .then(r => r.json())
  .then(data => {
    if (data.feuilles && data.feuilles.length > 1) {
      // Afficher le SELECT de sélection de feuille
      const select = document.getElementById('feuille-select-container');
      select.classList.remove('hidden');
      const sel = document.getElementById('id_feuille');
      data.feuilles.forEach(f => {
        const opt = document.createElement('option');
        opt.value = f; opt.textContent = f;
        sel.appendChild(opt);
      });
    }
  });
}
</script>

Bouton Suivant

<!-- Soumission du formulaire étape 1 -->
<form method="POST" action="/population/import/etape-1/" enctype="multipart/form-data">
  {% csrf_token %}
  <!-- Champs : description, date_reference, fichier, feuille -->

  <button type="submit" id="btn-suivant"
          class="btn-amber"
          onclick="this.disabled=true; this.textContent='Chargement...';">
    Suivant →
  </button>
</form>

<!-- Succès → Django traite et redirige vers /population/import/etape-2/<session_id>/ -->
<!-- Erreur → formulaire réaffiché avec messages -->

F.2 Étape 2 — Mapping (Écran 5.2)

Affichage du mapping et indicateurs

<!-- Chaque ligne de mapping -->
<div class="mapping-row flex items-center gap-4 py-2 border-b">
  <span class="field-label w-48">Matricule *</span>
  <span class="arrow text-slate-400"></span>
  <select name="map_matricule"
          class="mapping-select"
          onchange="checkMappingComplete()">
    <option value="">(choisir une colonne)</option>
    <option value="MAT" selected>MAT</option>
    <!-- ... colonnes du fichier ... -->
  </select>
  <span class="auto-badge text-green-600 text-sm">✅ auto</span>
</div>

Validation que les champs obligatoires sont mappés

const CHAMPS_OBLIGATOIRES = [
  'map_matricule', 'map_nom', 'map_date_naissance',
  'map_date_entree', 'map_salaire', 'map_sexe', 'map_categorie'
];

function checkMappingComplete() {
  const complet = CHAMPS_OBLIGATOIRES.every(champ => {
    const sel = document.querySelector(`[name="${champ}"]`);
    return sel && sel.value !== '';
  });

  document.getElementById('btn-valider-mapping').disabled = !complet;

  // Compteur de champs à compléter
  const manquants = CHAMPS_OBLIGATOIRES.filter(c => {
    const s = document.querySelector(`[name="${c}"]`);
    return !s || !s.value;
  }).length;

  document.getElementById('mapping-status').textContent =
    manquants === 0
      ? '✅ Tous les champs obligatoires sont mappés'
      : `⚠️ ${manquants} champ(s) obligatoire(s) à compléter`;
}

// Prévention des doublons de colonnes
document.querySelectorAll('.mapping-select').forEach(sel => {
  sel.addEventListener('change', () => {
    const usedValues = [...document.querySelectorAll('.mapping-select')]
      .map(s => s.value).filter(v => v !== '');

    document.querySelectorAll('.mapping-select').forEach(s => {
      [...s.options].forEach(opt => {
        if (opt.value && usedValues.includes(opt.value) && s.value !== opt.value) {
          opt.disabled = true;
        } else {
          opt.disabled = false;
        }
      });
    });
    checkMappingComplete();
  });
});

Mémorisation du mapping

<label>
  <input type="checkbox" name="memoriser_mapping" id="id_memoriser"
         checked>
  Mémoriser ce mapping pour ce client
</label>
<!-- Si coché → Django sauvegarde en cache (cache.set('mapping_client_<id>', ..., timeout=None)) -->

F.3 Étape 3 — Validation (Écran 5.3)

Polling du résultat de validation

<!-- Lancé au chargement de la page -->
<!-- La validation tourne en arrière-plan (Celery ou synchrone) -->

<div id="validation-result"
     hx-get="/population/import/<session_id>/statut-validation/"
     hx-trigger="load, every 2s [!validationTerminee]"
     hx-swap="outerHTML">
  <!-- État initial : spinner -->
  <div class="text-center py-8">
    [SPINNER] Validation en cours...
  </div>
</div>

<!-- Quand la validation est terminée → Django renvoie le HTML complet des résultats -->
<!-- + header HX-Trigger: {"validationTerminee": true} pour stopper le polling -->

<script>
let validationTerminee = false;
document.body.addEventListener('validationTerminee', () => {
  validationTerminee = true;
});
</script>

Tableau des erreurs — expandable

<!-- Section erreurs bloquantes -->
<details open>
  <summary class="cursor-pointer font-semibold text-red-600">
    ❌ 2 erreurs bloquantes — cliquez pour voir le détail
  </summary>
  <table>
    <thead>
      <tr><th>Ligne</th><th>Code</th><th>Colonne</th><th>Valeur</th><th>Description</th></tr>
    </thead>
    <tbody>
      <tr class="bg-red-50">
        <td>142</td><td>ERR-002</td><td>Date naissance</td>
        <td class="font-mono">32/13/80</td>
        <td>Format de date invalide</td>
      </tr>
    </tbody>
  </table>
</details>

Checkbox d'acceptation des avertissements

<!-- Affichée uniquement si des avertissements existent ET aucune erreur bloquante -->
<label class="flex items-start gap-2 mt-4">
  <input type="checkbox" id="id_accepte_avertissements"
         onchange="document.getElementById('btn-suivant').disabled = !this.checked">
  <span>J'ai examiné les avertissements listés ci-dessus et j'accepte de continuer
        malgré ces anomalies.</span>
</label>

<!-- Bouton Suivant désactivé tant que :
     - Des erreurs bloquantes existent, OU
     - Des avertissements existent et la checkbox n'est pas cochée -->
<button id="btn-suivant"
        disabled
        hx-post="/population/import/<session_id>/etape-4/"
        hx-target="body"
        hx-push-url="true">
  Suivant →
</button>

F.4 Étape 4 — Confirmation (Écran 5.4)

Lancement de l'import

<!-- Formulaire de confirmation finale -->
<form hx-post="/population/import/<session_id>/valider/"
      hx-target="#import-result"
      hx-swap="innerHTML"
      hx-indicator="#import-spinner">

  <!-- Récapitulatif en lecture seule (voir SFD) -->

  <div id="import-spinner" class="htmx-indicator">
    [SPINNER] Import en cours...
  </div>

  <button type="submit" id="btn-valider"
          class="btn-amber"
          hx-disabled-elt="this">
    Valider et importer
  </button>
</form>

<div id="import-result"></div>

États post-import

<!-- Succès (réponse HTMX dans #import-result) -->
<div class="bg-green-50 border border-green-200 rounded p-6">
  <p class="text-green-700 font-semibold text-xl">✅ Import réussi</p>
  <p>480 salariés ont été importés avec succès.</p>
  <p class="text-sm text-slate-500">
    Photographie datée du 31/12/2025 — enregistrée et immuable.
  </p>
  <div class="flex gap-3 mt-4">
    <a href="/population/<photo_id>/detail/" class="btn-secondary">
      Voir la liste des salariés
    </a>
    <a href="/clients/<client_id>/" class="btn-amber">
      Retour à l'espace client
    </a>
  </div>
</div>

<!-- Erreur (import échoué côté serveur) -->
<div class="bg-red-50 border border-red-200 rounded p-6">
  <p class="text-red-700 font-semibold">❌ Erreur lors de l'import</p>
  <p>Une erreur technique est survenue. Vos données n'ont pas été enregistrées.</p>
  <button hx-post="/population/import/<session_id>/valider/"
          hx-target="#import-result" hx-swap="innerHTML">
    Réessayer
  </button>
</div>

F.5 Historique et comparaison (Écrans 5.5–5.6)

Page de comparaison — sélecteurs liés

<!-- Les deux sélecteurs ne peuvent pas avoir la même valeur -->
<select name="photo_a" id="sel-photo-a" onchange="syncSelectors()">
  <option value="<id1>">31/12/2025 — 480 sal.</option>
  <option value="<id2>">31/12/2024 — 462 sal.</option>
  <option value="<id3>">31/12/2023 — 441 sal.</option>
</select>

<select name="photo_b" id="sel-photo-b" onchange="syncSelectors()">
  ...
</select>

<button hx-get="/population/comparer/"
        hx-include="#sel-photo-a, #sel-photo-b"
        hx-target="#comparaison-result"
        hx-swap="innerHTML"
        hx-indicator="#compare-spinner">
  Comparer
</button>

<script>
function syncSelectors() {
  const a = document.getElementById('sel-photo-a').value;
  const b = document.getElementById('sel-photo-b').value;
  // Désactiver dans B la valeur choisie en A
  [...document.getElementById('sel-photo-b').options].forEach(opt => {
    opt.disabled = (opt.value === a);
  });
}
</script>

PARTIE G — ÉVALUATIONS — TRONC COMMUN


G.1 Création d'une évaluation (Écran 6.1)

<form hx-post="/evaluations/nouvelle/"
      hx-target="#modal-content"
      hx-swap="innerHTML">

  <input type="hidden" name="dossier_id" value="<id>">

  <input name="nom" required minlength="3" maxlength="200"
         placeholder="ex : Scénario Base CEMAC 2025">

  <!-- Type d'évaluation -->
  <input type="radio" name="type_eval" value="IFC" id="type-ifc" checked>
  <label for="type-ifc">IFC — Indemnités de fin de carrière</label>

  <input type="radio" name="type_eval" value="EPS" id="type-eps">
  <label for="type-eps">Épargne salariale</label>

  <!-- Scénario (optionnel) -->
  <select name="scenario_id"
          hx-get="/scenarios/liste-select/"
          hx-trigger="load"
          hx-target="this"
          hx-swap="outerHTML">
    <option value="">Partir de zéro</option>
  </select>
  <!-- Réponse : <select> avec toutes les options scénarios -->

</form>
<!-- Succès → header HX-Redirect: /evaluations/<new_id>/donnees/ -->

G.2 Sélection du jeu de données (Écran 6.3)

Barre de progression des phases

<!-- Composant réutilisé dans toutes les pages d'évaluation -->
<div class="phase-stepper flex gap-4 mb-6">
  <div class="phase-step active" data-phase="1">
    <span class="step-number"></span>
    <span class="step-label">Données</span>
  </div>
  <div class="phase-divider"></div>
  <div class="phase-step" data-phase="2">
    <span class="step-number"></span>
    <span class="step-label">Paramétrage</span>
  </div>
  <div class="phase-divider"></div>
  <div class="phase-step" data-phase="3">
    <span class="step-number"></span>
    <span class="step-label">Calcul</span>
  </div>
  <div class="phase-divider"></div>
  <div class="phase-step" data-phase="4">
    <span class="step-number"></span>
    <span class="step-label">Résultats</span>
  </div>
</div>

<!-- Phase active : bg-amber-100, border-amber-500, texte amber-700 -->
<!-- Phase complétée : bg-green-50, icône ✅ à la place du numéro -->
<!-- Phase future : bg-slate-100, texte slate-400 -->

Sélection de la photographie

<!-- Radio buttons sur chaque ligne -->
<table>
  <tbody>
    <tr onclick="document.getElementById('radio-<id>').click()"
        class="cursor-pointer hover:bg-amber-50">
      <td>
        <input type="radio" name="photo_id" id="radio-<id>" value="<id>"
               onchange="document.getElementById('btn-utiliser').disabled = false">
      </td>
      <td>31/12/2025</td>
      <td>480 salariés</td>
      <td>15/01/2026</td>
      <td>Alice M.</td>
    </tr>
  </tbody>
</table>

<button id="btn-utiliser"
        disabled
        hx-post="/evaluations/<eval_id>/selectionner-donnees/"
        hx-include="[name='photo_id']"
        hx-target="body"
        hx-push-url="true">
  Utiliser le jeu sélectionné →
</button>
<!-- Succès → header HX-Redirect: /evaluations/<eval_id>/parametrage/ -->

PARTIE H — ÉVALUATION IFC


H.1 Paramétrage IFC (Écran 7.1)

Calcul dynamique du taux net

// Mis à jour à chaque changement des deux taux
function updateTauxNet() {
  const i = parseFloat(document.getElementById('id_taux_actualisation').value) / 100;
  const g = parseFloat(document.getElementById('id_taux_revalorisation').value) / 100;

  if (!isNaN(i) && !isNaN(g) && (1 + g) !== 0) {
    const net = ((1 + i) / (1 + g) - 1) * 100;
    document.getElementById('taux-net-display').textContent = net.toFixed(4) + ' %';
    document.getElementById('taux-net-display').classList.remove('text-slate-400');
    document.getElementById('taux-net-display').classList.add('text-navy');
  } else {
    document.getElementById('taux-net-display').textContent = '— %';
  }
}

document.getElementById('id_taux_actualisation').addEventListener('input', updateTauxNet);
document.getElementById('id_taux_revalorisation').addEventListener('input', updateTauxNet);
updateTauxNet(); // Calcul initial au chargement

Compteur de population (HTMX)

<!-- Mis à jour à chaque changement des checkboxes de filtre -->
<div class="flex gap-4">
  <label>
    <input type="checkbox" name="inclure_cdd" id="id_inclure_cdd"
           hx-post="/evaluations/<id>/compter-population/"
           hx-trigger="change"
           hx-target="#compteur-population"
           hx-swap="innerHTML"
           hx-include="#filtres-population">
    Inclure les CDD
  </label>
  <label>
    <input type="checkbox" name="inclure_suspendus" id="id_inclure_suspendus"
           hx-post="/evaluations/<id>/compter-population/"
           hx-trigger="change"
           hx-target="#compteur-population"
           hx-swap="innerHTML"
           hx-include="#filtres-population">
    Inclure les salariés suspendus
  </label>
  <label>
    <input type="checkbox" name="inclure_salaire_variable" id="id_inclure_salaire_variable"
           hx-post="/evaluations/<id>/compter-population/"
           hx-trigger="change"
           hx-target="#compteur-population"
           hx-swap="innerHTML"
           hx-include="#filtres-population">
    Inclure le salaire variable
  </label>
</div>

<span id="compteur-population">
  <!-- Réponse : ex. <span>487 salariés retenus</span> -->
  480 salariés retenus
</span>

Grille IFC personnalisée — éditeur dynamique

<div id="grille-custom-container" class="hidden">
  <table id="grille-table">
    <thead>
      <tr><th>De (ans)</th><th>À (ans)</th><th>Mois / an</th><th></th></tr>
    </thead>
    <tbody id="grille-rows">
      <!-- Lignes ajoutées dynamiquement -->
    </tbody>
  </table>
  <button type="button" onclick="addGrilleRow()">+ Ajouter une tranche</button>
</div>

<script>
let grilleRowCount = 0;

function addGrilleRow(de='', a='', mois='') {
  const tbody = document.getElementById('grille-rows');
  const idx = grilleRowCount++;
  const row = document.createElement('tr');
  row.id = `grille-row-${idx}`;
  row.innerHTML = `
    <td><input type="number" name="grille_de_${idx}" value="${de}" min="0" step="1" required></td>
    <td><input type="number" name="grille_a_${idx}" value="${a}" min="0" step="1"></td>
    <td><input type="number" name="grille_mois_${idx}" value="${mois}" min="0" step="0.25" required></td>
    <td><button type="button" onclick="removeGrilleRow(${idx})">🗑</button></td>
  `;
  tbody.appendChild(row);
}

function removeGrilleRow(idx) {
  document.getElementById(`grille-row-${idx}`).remove();
}

// Afficher/masquer selon le SELECT de grille
document.getElementById('id_grille_ifc').addEventListener('change', function() {
  const container = document.getElementById('grille-custom-container');
  container.classList.toggle('hidden', this.value !== 'custom');
  if (this.value === 'custom' && grilleRowCount === 0) {
    addGrilleRow(0, 2, 0.5);
    addGrilleRow(2, 5, 1.0);
    addGrilleRow(5, '', 1.5);
  }
});
</script>

Sauvegarde — comportement

<form id="param-form"
      hx-post="/evaluations/<id>/parametrage/sauvegarder/"
      hx-target="#param-feedback"
      hx-swap="innerHTML"
      hx-indicator="#param-spinner">

  <!-- Bouton sauvegarder comme scénario -->
  <button type="button"
          hx-post="/evaluations/<id>/parametrage/sauvegarder-scenario/"
          hx-include="#param-form"
          hx-target="#modal-content"
          hx-swap="innerHTML"
          onclick="document.getElementById('modal-overlay').classList.remove('hidden')">
    Sauvegarder comme scénario
  </button>

  <!-- Bouton principal -->
  <button type="submit" name="action" value="sauvegarder_lancer">
    Sauvegarder et lancer le calcul →
  </button>

  <div id="param-spinner" class="htmx-indicator">[SPINNER]</div>
  <div id="param-feedback"></div>
</form>

<!-- Succès → header HX-Redirect: /evaluations/<id>/calcul/ -->
<!-- Erreur validation → #param-feedback = liste des champs manquants -->

H.2 Calcul — lancement et suivi (Écran 7.4)

Lancement

<form hx-post="/evaluations/<id>/lancer-calcul/"
      hx-target="#calcul-status"
      hx-swap="outerHTML"
      hx-indicator="#launch-spinner">

  <div id="launch-spinner" class="htmx-indicator">[SPINNER] Soumission en cours...</div>

  <button type="submit" id="btn-lancer"
          hx-disabled-elt="this">
    Lancer le calcul actuariel
  </button>
</form>

Polling de progression (Celery)

<!-- Rendu côté serveur après lancement réussi -->
<div id="calcul-status"
     hx-get="/evaluations/<id>/statut-calcul/"
     hx-trigger="every 3s"
     hx-swap="outerHTML">

  <!-- Contenu rendu par Django selon l'état Celery -->

  <!-- Si EN COURS : -->
  <div class="bg-blue-50 rounded p-6">
    [BADGE BLUE] ⟳ Calcul en cours...
    <div class="progress-bar">
      <div style="width: {{ progression }}%"></div>
    </div>
    <p>{{ nb_traites }} salariés traités sur {{ nb_total }}</p>
    <p>Durée estimée : ~{{ duree_estimee }} secondes</p>
  </div>

  <!-- Si TERMINÉ : arrêt du polling via HX-Trigger -->
  <!-- header HX-Trigger: {"calculTermine": true} -->
  <!-- + redirection auto après 2s -->

  <!-- Si ERREUR : -->
  <div class="bg-red-50 rounded p-6">
    [BADGE ROUGE] ❌ Erreur de calcul
    <p>{{ message_erreur }}</p>
    <button hx-post="/evaluations/<id>/lancer-calcul/">Relancer</button>
  </div>

</div>

<script>
document.body.addEventListener('calculTermine', () => {
  // Arrêter le polling
  document.getElementById('calcul-status').removeAttribute('hx-trigger');
  // Redirection après 2 secondes
  setTimeout(() => {
    window.location.href = '/evaluations/<id>/resultats/';
  }, 2000);
});
</script>

H.3 Résultats — Synthèse (Écran 7.5)

<!-- Onglets internes à la page résultats -->
<div class="result-tabs flex border-b">
  <button class="result-tab active"
          hx-get="/evaluations/<id>/resultats/synthese/"
          hx-target="#result-content"
          hx-swap="innerHTML"
          onclick="setResultTab(this)">
    Synthèse
  </button>
  <button class="result-tab"
          hx-get="/evaluations/<id>/resultats/detail/"
          hx-target="#result-content"
          hx-swap="innerHTML"
          onclick="setResultTab(this)">
    Détail individuel
  </button>
  <button class="result-tab"
          hx-get="/evaluations/<id>/resultats/projections/"
          hx-target="#result-content"
          hx-swap="innerHTML"
          onclick="setResultTab(this)">
    Projections
  </button>
  <button class="result-tab"
          hx-get="/evaluations/<id>/resultats/sensibilite/"
          hx-target="#result-content"
          hx-swap="innerHTML"
          onclick="setResultTab(this)">
    Analyse de sensibilité
  </button>
</div>

<div id="result-content">
  <!-- Contenu chargé par HTMX selon l'onglet actif -->
</div>

Export Excel

<!-- Téléchargement direct — pas HTMX -->
<a href="/evaluations/<id>/export-excel/"
   class="btn-secondary">
  ⬇ Export Excel
</a>
<!-- Django génère le fichier openpyxl et renvoie en Content-Disposition: attachment -->

Bouton Générer rapport

<a href="/evaluations/<id>/rapport/" class="btn-amber">
  📄 Générer rapport
</a>
<!-- Navigation standard vers l'écran 10.x -->

H.4 Résultats — Détail, Projections, Sensibilité (Écrans 7.6–7.8)

Détail individuel — filtres et pagination

<!-- Recherche dans les résultats individuels -->
<input name="q"
       hx-get="/evaluations/<id>/resultats/detail/"
       hx-trigger="keyup changed delay:350ms"
       hx-target="#detail-table"
       hx-swap="outerHTML"
       hx-include="#detail-filters"
       placeholder="Matricule ou nom...">

<select name="categorie"
        hx-get="/evaluations/<id>/resultats/detail/"
        hx-trigger="change"
        hx-target="#detail-table"
        hx-swap="outerHTML"
        hx-include="#detail-filters">
  <option value="">Toutes catégories</option>
  ...
</select>

<!-- Tri par colonne (clic sur en-tête) -->
<th onclick="sortTable('dbo', 'desc')"
    class="cursor-pointer hover:bg-slate-100">
  DBO ↕
</th>
<!-- Paramètre sort= et order= ajouté à la requête HTMX -->

Projections — recalcul à la demande

<select name="horizon"
        id="id_horizon">
  <option value="3">3 ans</option>
  <option value="5" selected>5 ans</option>
  <option value="10">10 ans</option>
</select>

<button hx-post="/evaluations/<id>/resultats/projections/"
        hx-include="#id_horizon"
        hx-target="#projections-table"
        hx-swap="outerHTML"
        hx-indicator="#proj-spinner">
  Recalculer
</button>

Sensibilité — sélection des hypothèses

<div id="sensibilite-config">
  <label>
    <input type="checkbox" name="sens_actualisation" checked>
    Taux d'actualisation
  </label>
  <!-- Variations prédéfinies : -1% -0.5% +0.5% +1% — non modifiables par l'utilisateur -->

  <label>
    <input type="checkbox" name="sens_revalorisation">
    Taux de revalorisation
  </label>
  <!-- Variations : -0.5% +0.5% -->

  <label>
    <input type="checkbox" name="sens_rotation">
    Taux de rotation
  </label>
  <!-- Variations : -2% +2% -->

  <label>
    <input type="checkbox" name="sens_retraite">
    Âge de retraite
  </label>
  <!-- Variations : -1 an +1 an -->
</div>

<button hx-post="/evaluations/<id>/resultats/sensibilite/"
        hx-include="#sensibilite-config"
        hx-target="#sensibilite-result"
        hx-swap="outerHTML"
        hx-indicator="#sens-spinner">
  Calculer la sensibilité
</button>

<!-- Bouton inclure dans rapport -->
<button hx-post="/evaluations/<id>/rapport/inclure-sensibilite/"
        hx-swap="none">
  ✅ Inclure dans le rapport
</button>
<!-- Réponse : HX-Trigger: {"showToast": {"message": "Sensibilité ajoutée au rapport.", "type": "success"}} -->

PARTIE I — ÉVALUATION ÉPARGNE SALARIALE


I.1 Paramétrage épargne (Écrans 8.1–8.5)

Sélection du mode de vesting (cards interactives)

<div id="vesting-cards" class="grid grid-cols-2 gap-4">

  <label class="vesting-card cursor-pointer border-2 rounded-lg p-4
                hover:border-amber-400 transition-colors
                [has([type=radio]:checked)]:border-amber-500
                [has([type=radio]:checked)]:bg-amber-50">
    <input type="radio" name="mode_vesting" value="immediat" class="sr-only">
    <h3 class="font-semibold">IMMÉDIAT</h3>
    <p class="text-sm text-slate-500">Droits acquis dès le 1er jour</p>
  </label>

  <label class="vesting-card cursor-pointer border-2 rounded-lg p-4 ...">
    <input type="radio" name="mode_vesting" value="lineaire" class="sr-only">
    <h3 class="font-semibold">LINÉAIRE</h3>
    <p class="text-sm text-slate-500">Acquisition progressive</p>
  </label>

  <label class="vesting-card ...">
    <input type="radio" name="mode_vesting" value="cliff" class="sr-only">
    <h3 class="font-semibold">CLIFF</h3>
    <p class="text-sm text-slate-500">0% puis 100% après N années</p>
  </label>

  <label class="vesting-card ...">
    <input type="radio" name="mode_vesting" value="grille" class="sr-only">
    <h3 class="font-semibold">GRILLE</h3>
    <p class="text-sm text-slate-500">Paliers personnalisés</p>
  </label>
</div>

<!-- Champ durée (affiché si LINEAIRE ou CLIFF) -->
<div id="vesting-duree-container" class="hidden mt-4">
  <label>Durée de vesting *
    <input type="number" name="duree_vesting" min="1" max="10" step="1">
    ans
  </label>
</div>

<!-- Éditeur de paliers (affiché si GRILLE) -->
<div id="vesting-grille-container" class="hidden mt-4">
  <!-- Même pattern que la grille IFC (addRow / removeRow) -->
</div>

<script>
document.querySelectorAll('[name="mode_vesting"]').forEach(radio => {
  radio.addEventListener('change', function() {
    const duree  = document.getElementById('vesting-duree-container');
    const grille = document.getElementById('vesting-grille-container');

    duree.classList.toggle('hidden',  !['lineaire', 'cliff'].includes(this.value));
    grille.classList.toggle('hidden', this.value !== 'grille');

    if (this.value === 'grille' && grilleVestingCount === 0) {
      // Pré-remplir 4 paliers par défaut
      addVestingRow(0, 1, 0);
      addVestingRow(1, 2, 25);
      addVestingRow(2, 3, 50);
      addVestingRow(3, null, 100);
    }
  });
});
</script>

Même compteur population et taux net que IFC

Comportement identique à H.1 — mêmes attributs HTMX.
Endpoint différent : /evaluations/<id>/compter-population-epargne/

I.2 Calcul et résultats épargne (Écrans 8.6–8.8)

Comportement identique à H.2 / H.3 / H.4.
Endpoints distincts mais pattern HTMX identique.

Différences résultats :
  - KPIs : VAPE, Capital projeté, Part vestée, Capital moyen
  - Onglets : Synthèse / Comptes individuels
  - Pas d'onglet Sensibilité (non applicable à l'épargne en v2.0)

PARTIE J — SCÉNARIOS


J.1 Bibliothèque et création (Écrans 9.1–9.3)

Duplication depuis menu [⋮]

<button hx-post="/scenarios/<id>/dupliquer/"
        hx-target="#scenarios-list"
        hx-swap="beforeend"
        hx-swap="outerHTML">
  Dupliquer
</button>
<!-- Réponse : HTML de la nouvelle ligne scénario + toast "Scénario dupliqué." -->

Archivage (scénario utilisé)

Si le scénario est utilisé dans ≥ 1 évaluation :
  Option "Supprimer" absente du menu [⋮]
  Option "Archiver" présente
  → hx-confirm : « Ce scénario est utilisé dans X évaluation(s).
                   Il sera archivé et ne sera plus sélectionnable.
                   Les évaluations existantes ne sont pas affectées. »

J.2 Comparaison de scénarios (Écran 9.4)

<!-- Même pattern que la comparaison de photographies -->
<!-- Deux SELECT liés — valeurs mutuellement exclusives -->
<!-- Bouton "Comparer" → HTMX GET → tableau de différences -->

<button hx-get="/scenarios/comparer/"
        hx-include="#sel-scenario-a, #sel-scenario-b"
        hx-target="#compare-result"
        hx-swap="innerHTML">
  Comparer
</button>

<!-- Résultat : tableau ligne à ligne avec delta par hypothèse -->
<!-- Surbrillance amber sur les différences -->

PARTIE K — RAPPORTS


K.1 Génération et versionnement (Écrans 10.1–10.3)

Prévisualisation

<button hx-get="/evaluations/<id>/rapport/preview/"
        hx-target="#rapport-preview-frame"
        hx-swap="innerHTML"
        hx-indicator="#preview-spinner">
  Prévisualiser
</button>

<!-- La prévisualisation s'ouvre dans un iframe ou une zone dédiée -->
<div id="rapport-preview-frame" class="border rounded mt-4 h-96 overflow-y-auto">
  <!-- HTML du rapport injecté ici (sans WeasyPrint — rendu HTML direct) -->
</div>

Génération PDF

<form hx-post="/evaluations/<id>/rapport/generer/"
      hx-target="#rapport-result"
      hx-swap="innerHTML"
      hx-indicator="#gen-spinner">

  <!-- Checkboxes sections -->
  <!-- Select actuaire signataire -->

  <div id="gen-spinner" class="htmx-indicator">
    [SPINNER] Génération du rapport en cours...
  </div>
  <div id="rapport-result"></div>

  <button type="submit">Générer le rapport PDF</button>
</form>

<!-- Réponse succès (dans #rapport-result) :
<div class="bg-green-50 p-4 rounded">
  <p>✅ Rapport v3 généré.</p>
  <a href="/evaluations/<id>/rapport/<version_id>/telecharger/">
    ⬇ Télécharger le PDF
  </a>
</div>
-->

Marquage rapport final

<!-- Affiché uniquement pour [AC] et [AD], sur la version la plus récente -->
<button hx-post="/evaluations/<id>/rapport/<version_id>/marquer-final/"
        hx-confirm="Marquer cette version comme rapport final ?
                    Cette action est irréversible."
        hx-target="#version-row-<version_id>"
        hx-swap="outerHTML">
  Marquer comme rapport final
</button>
<!-- Réponse : ligne mise à jour avec [BADGE NAVY] Rapport final -->

PARTIE L — DASHBOARD & NOTIFICATIONS


L.1 Tableau de bord BFEV (Écran 11.1)

Personnalisation selon le rôle

[AD] : voit tout — toutes les missions, tous les clients, file Celery complète
[AC] : voit ses missions + ses clients + son activité
[CO] : voit ses dossiers affectés uniquement

Implémentation Django :
  → queryset filtré dans la view par request.user.role
  → Aucune logique côté template — le template reçoit des données déjà filtrées

Message de bienvenue dynamique

// Salutation selon l'heure locale
function getSalutation() {
  const h = new Date().getHours();
  if (h < 12) return 'Bonjour';
  if (h < 18) return 'Bon après-midi';
  return 'Bonsoir';
}
document.getElementById('salutation').textContent = getSalutation() + ', ' + userName + ' 👋';

Alertes actives

<!-- Section alertes — chargée au rendu initial -->
<!-- Pas de polling (rechargement à la navigation) -->
<div id="alertes-section">
  {% for alerte in alertes %}
  <div class="alert-card border-l-4 border-red-500 bg-red-50 p-4 rounded mb-2">
    <p class="font-semibold text-red-700">{{ alerte.titre }}</p>
    <p class="text-sm">{{ alerte.message }}</p>
    <a href="{{ alerte.url_action }}" class="btn-amber-sm mt-2">
      {{ alerte.label_action }}
    </a>
  </div>
  {% endfor %}
</div>

L.2 Centre de notifications (Écran 11.3)

Panneau latéral

<!-- Ouverture -->
<button id="notif-btn"
        hx-get="/notifications/panneau/"
        hx-target="#notif-panel"
        hx-swap="innerHTML"
        hx-trigger="click"
        @click="panelOpen = !panelOpen">
  🔔 <span id="notif-badge">{{ nb_non_lues }}</span>
</button>

<!-- Panneau -->
<aside id="notif-panel"
       class="fixed right-0 top-0 h-full w-96 bg-white shadow-2xl z-30
              transform transition-transform duration-300"
       :class="panelOpen ? 'translate-x-0' : 'translate-x-full'"
       @click.outside="panelOpen = false">
</aside>

Marquer comme lu (HTMX)

<!-- Par notification individuelle -->
<button hx-post="/notifications/<id>/lire/"
        hx-target="#notif-item-<id>"
        hx-swap="outerHTML">
  Marquer comme lu
</button>

<!-- Tout marquer comme lu -->
<button hx-post="/notifications/tout-lire/"
        hx-target="#notif-panel"
        hx-swap="innerHTML">
  Tout marquer comme lu
</button>
<!-- + header HX-Trigger: {"updateBadge": 0} pour mettre à jour le compteur topbar -->

PARTIE M — RÉFÉRENTIELS


M.1 Tables de mortalité (Écran 12.1)

Import d'une table personnalisée

<button hx-get="/referentiels/tables-mortalite/importer/"
        hx-target="#modal-content"
        hx-swap="innerHTML"
        onclick="document.getElementById('modal-overlay').classList.remove('hidden')">
  + Import
</button>

<!-- Modal d'import -->
<form hx-post="/referentiels/tables-mortalite/importer/"
      hx-target="#modal-content"
      hx-swap="innerHTML"
      enctype="multipart/form-data">

  <input name="nom" placeholder="Nom de la table *" required>
  <select name="sexe">
    <option value="H">Hommes</option>
    <option value="F">Femmes</option>
    <option value="HF">Mixte</option>
  </select>
  <input type="file" name="fichier_csv" accept=".csv" required>
  <p class="text-sm text-slate-400">
    Format CSV attendu : colonnes age, lx_H, lx_F (ou lx si mixte)
  </p>

  <button type="submit">Importer la table</button>
</form>

Vue détail d'une table

<!-- Clic sur une ligne → expansion avec aperçu des valeurs qx -->
<tr onclick="toggleTableDetail('<id>')" class="cursor-pointer hover:bg-slate-50">
  <td>CIMA 2018</td>
  ...
</tr>
<tr id="table-detail-<id>" class="hidden">
  <td colspan="5">
    <!-- Rendu HTMX -->
    <div hx-get="/referentiels/tables-mortalite/<id>/apercu/"
         hx-trigger="load"
         hx-swap="innerHTML">
      [SPINNER]
    </div>
  </td>
</tr>

M.2 Grilles IFC (Écran 12.2)

Création d'une grille

<!-- Modal avec éditeur de tranches — même pattern que H.1 grille personnalisée -->
<!-- Champs supplémentaires : Nom, Pays, Description, Date d'entrée en vigueur -->

<!-- Versionnement :
     Si la grille est utilisée dans ≥ 1 évaluation :
       - Modification impossible
       - Bouton "Nouvelle version" → duplique avec numéro de version incrémenté
-->

ANNEXE 1 — COMPOSANTS RÉUTILISABLES


Composant : Toast

<!-- Template de base (injecté dynamiquement par showToast()) -->
<div class="toast flex items-start gap-3 px-4 py-3 rounded-lg shadow-lg
            animate-slide-up min-w-72 max-w-sm"
     :class="{
       'bg-green-600 text-white': type === 'success',
       'bg-red-600 text-white':   type === 'error',
       'bg-amber-500 text-white': type === 'warning',
       'bg-slate-700 text-white': type === 'info',
     }">
  <span class="icon">{{ icon }}</span>
  <span class="message flex-1">{{ message }}</span>
  <button onclick="this.parentElement.remove()" class="opacity-70 hover:opacity-100"></button>
</div>

Composant : Confirmation modal (générique)

<!-- Ouvert par hx-confirm (natif navigateur) ou par modal personnalisée -->
<!-- Pour les confirmations critiques (archivage, suppression) → modal personnalisée -->

<div id="confirm-modal" class="hidden">
  <div class="modal-box">
    <h3 id="confirm-title">Confirmation</h3>
    <p id="confirm-message"></p>

    <!-- Si confirmation par saisie -->
    <input id="confirm-input" type="text" class="hidden"
           oninput="checkConfirmInput()">
    <p id="confirm-hint" class="text-sm text-slate-400 hidden"></p>

    <div class="flex gap-3 justify-end mt-4">
      <button onclick="closeConfirmModal()" class="btn-secondary">Annuler</button>
      <button id="confirm-ok-btn" class="btn-danger">Confirmer</button>
    </div>
  </div>
</div>

Composant : Badge de statut

Statut dossier :
  En attente de données : bg-slate-100, texte slate-600, icône ○
  En cours              : bg-amber-100, texte amber-700, icône ●
  En validation         : bg-blue-100,  texte blue-700,  icône ◉
  Livré                 : bg-green-100, texte green-700, icône ✅
  Archivé               : bg-slate-200, texte slate-500, icône 🗄

Statut collaborateur :
  Actif   : ● vert
  Invité  : ○ amber + texte « En attente »
  Inactif : ○ gris

Statut photographie :
  Valide   : ● vert
  En cours : ⟳ blue
  Erreur   : ✕ rouge

Composant : Indicateur de chargement HTMX

<!-- Inséré dans chaque bloc utilisant hx-indicator -->
<div class="htmx-indicator flex items-center gap-2 py-2 text-slate-500">
  <svg class="animate-spin h-4 w-4" ...>...</svg>
  <span>Chargement...</span>
</div>

<!-- CSS global -->
<style>
  .htmx-indicator { display: none; }
  .htmx-request .htmx-indicator { display: flex; }
  .htmx-request.htmx-indicator  { display: flex; }
</style>

ANNEXE 2 — ÉTATS VIDES PAR MODULE

Écran Condition Message Action proposée
Liste clients Aucun client affecté « Aucun client ne vous a été affecté. » — (contact admin)
Liste clients Filtre sans résultat « Aucun client ne correspond à vos filtres. » Réinitialiser filtres
Onglet dossiers Aucun dossier « Aucun dossier pour ce client. » + Créer un dossier
Onglet données Aucune photographie « Aucune donnée salariée importée. » Importer des données
Résultats détail Aucun résultat filtré « Aucun salarié ne correspond à la recherche. » Effacer la recherche
Journal audit Aucun événement filtré « Aucun événement pour cette période. » Réinitialiser filtres
Notifications Aucune notification « Vous êtes à jour. Aucune notification. »
Scénarios globaux Aucun scénario « Aucun scénario BFEV n'a encore été créé. » + Créer un scénario

ANNEXE 3 — ACCÈS PAR RÔLE ET PAR ÉCRAN

URL [AD] [AC] [CO] Condition d'accès supplémentaire
/auth/* Sans authentification
/dashboard/
/admin/
/admin/collaborateurs/
/admin/audit/
/clients/ [AC]/[CO] : filtrés sur affectations
/clients/nouveau/
/clients/<id>/ [AC]/[CO] : client affecté seulement
/missions/nouveau/
/missions/<id>/ [CO] : dossier affecté seulement
/population/import/* Client affecté
/evaluations/nouvelle/
/evaluations/<id>/parametrage/
/evaluations/<id>/calcul/
/evaluations/<id>/resultats/ Dossier affecté
/evaluations/<id>/rapport/
/scenarios/ [CO] : lecture seule
/referentiels/*
/profil/ Soi-même uniquement