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
Breadcrumb¶
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)¶
Navigation entrante / sortante¶
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)¶
Navigation entrante / sortante¶
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)¶
Navigation¶
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>
Modal changement de mot de passe¶
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>
Menu contextuel [⋮]¶
<!-- 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)¶
Navigation¶
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)¶
Navigation¶
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>
Modal d'archivage (confirmation par saisie)¶
<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)¶
Navigation¶
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)¶
Modal avec pré-remplissage scénario¶
<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)¶
Navigation par onglets (résultats)¶
<!-- 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 |