INRACI · Forest · 2025 – 2026

Gestion d'emprunt
de matériel.

Système de casiers connectés pour automatiser la gestion du matériel technique. Réservez en ligne, récupérez en autonomie.

Accéder à l'application En savoir plus
18+
Équipements
3
Niveaux d'accès
0
Interaction requise
102
Budget matériel
Réservation en ligne
ESP32 · Firebase
Code d'accès unique
Flask · SendGrid
Casier connecté
Amendes automatiques
INRACI 2026
PythonAnywhere
Réservation en ligne
ESP32 · Firebase
Code d'accès unique
Flask · SendGrid
Casier connecté
Amendes automatiques
INRACI 2026
PythonAnywhere
Contexte & objectifs

Pourquoi Loan Track ?

Un constat simple à l'origine d'un système complet — remplacer le papier par du numérique et la dépendance humaine par l'autonomie.

À l'INRACI, la gestion du matériel reposait sur des registres papier ou des tableaux Excel. Résultat : erreurs de stock, perte d'équipements et obligation pour l'accueil d'être physiquement présent à chaque échange.

Loan Track automatise et sécurise ce processus grâce à une application web couplée à un casier physique connecté — zéro papier, traçabilité complète, disponible 24h/24.

Le problème

Gestion manuelle, non traçable

Registres papier sujets aux erreurs, stock impossible à suivre, dépendance totale à la présence d'un responsable pour chaque emprunt ou retour.

La solution

Casier autonome & application web

Réservation en ligne, code d'accès 6 chiffres envoyé par email, casier ouvert sans contact. Stock mis à jour instantanément via Firebase.

Stack technique

Technologies utilisées

Choisies pour leur fiabilité, leur documentation et leur compatibilité avec un déploiement cloud accessible.

ESP32
Microcontrôleur Wi-Fi

Cerveau du casier physique. Gère le capteur infrarouge (debounce 5 lectures), le relais 12V et le polling HTTPS toutes les 3 secondes vers Flask.

🐍
Python · Flask
Backend & routing

Framework léger pour les routes HTTP, la logique métier, les sessions et le rendu Jinja2. ~1 189 lignes de code applicatif.

🔥
Firebase Realtime DB
Base de données temps réel

Lien central entre Flask et l'ESP32. Stocke les utilisateurs, le stock, les emprunts et l'état des casiers avec synchronisation instantanée.

📧
SendGrid API
Emails transactionnels

7 types d'emails automatisés — confirmation emprunt avec code, alertes retard, reset MDP, notifications de message et paiement.

☁️
PythonAnywhere
Hébergement cloud

Déploiement Flask stable, accessible 24h/24 à l'adresse loantrack.pythonanywhere.com sans maintenance serveur manuelle.

🔒
Sécurité
SHA-256 · SSH · Firebase Auth

MDP hashés SHA-256, sessions Flask, décorateurs @login_required et @role_required. Maintenance ESP32 à distance via SSH sur le LAN.

Fonctionnalités

Ce que fait Loan Track

De la réservation au retour — tout le cycle de vie d'un emprunt, automatisé.

📦

Catalogue temps réel

18 équipements — MacBook Pro, iPad, Arduino, Sony Alpha 7, microscope… Stock affiché en temps réel via Firebase.

🔑

Code d'accès unique

Code 6 chiffres généré et envoyé par email à chaque réservation. Ouvre le casier physique sans contact humain.

⚠️

Amendes automatiques

1€ dès la 1ʳᵉ minute de retard, +1€/jour. Alertes email automatiques. Paiement par carte intégré dans l'app.

💬

Messagerie interne

Fil de discussion entre élèves, responsables et admins. Notifications email à chaque nouveau message.

👥

3 niveaux de rôles

Élève · Responsable · Administrateur. Permissions distinctes gérées par décorateurs Flask.

📊

Journal d'activité

Chaque action est horodatée — emprunts, retours, paiements, connexions. Traçabilité complète pour l'admin.

Mode d'emploi

4 étapes pour emprunter

Aucune interaction humaine requise. Le système gère tout de façon autonome.

01

Connexion

L'élève se connecte avec son adresse INRACI. Le rôle est détecté automatiquement.

02

Réservation

Consultation du catalogue, vérification du stock en temps réel, sélection de la durée.

03

Code reçu

Code unique à 6 chiffres généré et envoyé immédiatement par SendGrid.

04

Retrait autonome

Code saisi dans l'app → ESP32 reçoit l'ordre → relais activé → verrou ouvert.

Infrastructure

Topologie réseau

L'infrastructure du projet sépare les accès distants via le Cloud public et la gestion physique locale au sein de l'école (INRACI). Tous les flux convergent vers le nœud central : Internet.

Schéma de la topologie réseau Loan Track
Schéma de la topologie réseau — Loan Track · INRACI

Description de la topologie

01 Acteurs distants (Zone Internet) : L'utilisateur (smartphone) et le responsable (PC/Mac) initient des requêtes HTTPS vers l'application Flask hébergée sur PythonAnywhere. Flask communique en JSON avec Firebase Realtime Database pour la persistance et la synchronisation temps réel via WebSockets.
02 Infrastructure IoT locale (Zone réseau INRACI — 192.168.1.0/24) : L'ESP32 est connecté au Wi-Fi de l'école avec un bail DHCP statique réservé (192.168.1.200). Il interroge Flask toutes les 3 secondes via polling HTTPS sécurisé. Dès qu'une commande est détectée, il active le relais pour ouvrir le verrou, et le capteur infrarouge TCRT5000 valide la présence de l'objet.
03 Flux de maintenance SSH : L'administrateur (IP : 192.168.1.20) peut établir une session SSH directe vers l'ESP32 depuis le réseau local. Ce flux est strictement interne au LAN — il ne transite jamais par Internet, garantissant la sécurité lors des phases de débogage ou de mise à jour du firmware.
04 Flux applicatif temps réel : Réservation → Flask écrit dans Firebase → ESP32 intercepte via polling → relais activé → verrou ouvert → capteur IR confirme le retrait → Firebase mis à jour → stock actualisé sur le site web. Toute la chaîne s'exécute en moins de 3 secondes.
Documentation

Code source

🐍 flask_app.py
🌐 index.html
🎨 style.css
⚡ ESP32
🔌 Arduino Tests
💾 PowerShell

Télécharger le code source

Récupérez l'archive complète du code source de l'application web Flask.

🐍
Appli.zip
Application web — Flask / Python
Télécharger

Application Flask — Backend principal

Fichier principal du backend. Gère les routes HTTP, l'authentification par rôle (élève / responsable / admin), le cycle complet des emprunts, le calcul automatique des amendes, la messagerie interne et l'envoi d'emails via SendGrid. Hébergé sur PythonAnywhere.

Flask 3Firebase Admin SDKSendGrid v3SHA-2561 189 lignes
flask_app.py — 1 189 lignesPython
   1from flask import Flask, render_template, request, redirect, url_for, session, flash
   2from datetime import datetime, timedelta
   3import hashlib, random, string
   4from functools import wraps
   5
   6import firebase_admin
   7from firebase_admin import credentials, auth as firebase_auth
   8
   9SENDGRID_API_KEY = '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'
  10SENDGRID_FROM    = 'atmacenes@gmail.com'
  11
  12def send_email(to, subject, body_html):
  13    try:
  14        import urllib.request, json
  15        data = json.dumps({
  16            "personalizations": [{"to": [{"email": to}]}],
  17            "from": {"email": SENDGRID_FROM, "name": "LoanTrack INRACI"},
  18            "subject": subject,
  19            "content": [{"type": "text/html", "value": body_html}]
  20        }).encode('utf-8')
  21        req = urllib.request.Request(
  22            "https://api.sendgrid.com/v3/mail/send", data=data,
  23            headers={"Authorization": f"Bearer {SENDGRID_API_KEY}", "Content-Type": "application/json"},
  24            method="POST"
  25        )
  26        urllib.request.urlopen(req, timeout=5)
  27        return True
  28    except Exception as e:
  29        print(f"[SendGrid] {e}")
  30        return False
  31
  32def email_html(couleur, contenu):
  33    return f"""<div style="font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;max-width:520px;margin:0 auto;background:#f0f4f8;border-radius:14px;overflow:hidden;border:1px solid #d0dbe8;box-shadow:0 4px 24px rgba(26,35,50,0.10)">
  34    <div style="background:{couleur};padding:24px 32px;display:flex;align-items:center;gap:12px">
  35        <div style="background:rgba(255,255,255,0.18);border-radius:9px;padding:7px 13px;font-weight:900;font-size:0.95rem;color:white;letter-spacing:-0.02em">LT</div>
  36        <div><h2 style="color:white;margin:0;font-size:1rem;font-weight:700;letter-spacing:0.3px">LoanTrack — INRACI</h2></div>
  37    </div>
  38    <div style="padding:32px;color:#1a2332;line-height:1.7;background:#ffffff">{contenu}</div>
  39    <div style="background:#f0f4f8;padding:14px 32px;text-align:center;border-top:1px solid #d0dbe8"><p style="color:#8a9aaa;font-size:11px;margin:0;font-family:'Inter',sans-serif">LoanTrack INRACI · Ne pas répondre à cet email</p></div>
  40    </div>"""
  41
  42def email_confirmation_emprunt(user_email, user_name, mat_nom, code, date_debut, date_retour):
  43    send_email(user_email, "✅ LoanTrack — Emprunt confirmé", email_html("#2563eb",
  44        f"""<h3 style="margin:0 0 16px;color:#1a2332;font-family:'Inter',sans-serif">Bonjour {user_name},</h3>
  45        <p style="color:#4a5a6a;margin-bottom:20px;font-family:'Inter',sans-serif">Votre emprunt a bien été enregistré.</p>
  46        <div style="background:#f0f4f8;border-radius:10px;padding:18px;margin-bottom:18px;border-left:4px solid #2563eb">
  47            <p style="margin:0 0 6px;font-weight:700;color:#1a2332;font-family:'Inter',sans-serif">{mat_nom}</p>
  48            <p style="margin:0 0 4px;color:#4a5a6a;font-size:13px;font-family:'Inter',sans-serif">Début : {date_debut}</p>
  49            <p style="margin:0;color:#dc2626;font-size:13px;font-family:'Inter',sans-serif">À rendre le : <strong>{date_retour}</strong></p>
  50        </div>
  51        <div style="background:#1a2332;border-radius:10px;padding:20px;text-align:center;margin-bottom:16px">
  52            <p style="color:#8a9aaa;font-size:11px;margin-bottom:8px;letter-spacing:2px;font-family:'Inter',sans-serif;text-transform:uppercase">Code de retrait</p>
  53            <p style="font-size:2.2rem;font-weight:800;color:#60a5fa;letter-spacing:10px;font-family:monospace;margin:0">{code}</p>
  54        </div>
  55        <p style="color:#dc2626;font-size:13px;margin:0;font-family:'Inter',sans-serif">⚠ Retard : 1 € par jour automatiquement.</p>"""))
  56
  57def email_changement_mdp(user_email, user_name, new_mdp):
  58    send_email(user_email, "🔑 LoanTrack — Votre nouveau mot de passe", email_html("#2563eb",
  59        f"""<h3 style="margin:0 0 16px;color:#1a2332;font-family:'Inter',sans-serif">Bonjour {user_name},</h3>
  60        <p style="color:#4a5a6a;margin-bottom:16px;font-family:'Inter',sans-serif">Votre mot de passe a bien été réinitialisé.</p>
  61        <div style="background:#1a2332;border-radius:10px;padding:20px;text-align:center;margin-bottom:16px">
  62            <p style="color:#8a9aaa;font-size:11px;margin-bottom:8px;letter-spacing:2px;font-family:'Inter',sans-serif;text-transform:uppercase">Nouveau mot de passe</p>
  63            <p style="font-size:1.6rem;font-weight:700;color:#60a5fa;letter-spacing:6px;font-family:monospace;margin:0">{new_mdp}</p>
  64        </div>
  65        <p style="color:#dc2626;font-size:13px;font-family:'Inter',sans-serif;">⚠ Connectez-vous et changez-le dès que possible dans votre profil.</p>"""))
  66
  67def email_nouveau_message(to_email, from_name, sujet, contenu):
  68    send_email(to_email, f"💬 LoanTrack — Nouveau message : {sujet}", email_html("#2563eb",
  69        f"""<h3 style="margin:0 0 16px;color:#1a2332;font-family:'Inter',sans-serif">Nouveau message de {from_name}</h3>
  70        <div style="background:#f0f4f8;border-radius:10px;padding:18px;margin-bottom:18px;border-left:4px solid #2563eb">
  71            <p style="margin:0 0 6px;font-weight:700;color:#1a2332;font-family:'Inter',sans-serif">Sujet : {sujet}</p>
  72            <p style="margin:0;color:#4a5a6a;font-size:14px;font-family:'Inter',sans-serif;white-space:pre-wrap">{contenu}</p>
  73        </div>
  74        <p style="color:#4a5a6a;font-size:13px;font-family:'Inter',sans-serif">Connectez-vous sur LoanTrack pour répondre.</p>"""))
  75
  76def email_paiement(user_email, user_name, mat_nom, montant, ref, card_last4):
  77    send_email(user_email, "💳 LoanTrack — Confirmation de paiement", email_html("#16a34a",
  78        f"""<h3 style="margin:0 0 16px;color:#1a2332;font-family:'Inter',sans-serif">Bonjour {user_name},</h3>
  79        <p style="color:#4a5a6a;margin-bottom:16px;font-family:'Inter',sans-serif">Votre paiement a bien été reçu.</p>
  80        <div style="background:#f0f4f8;border-radius:10px;padding:18px;margin-bottom:18px;border-left:4px solid #16a34a">
  81            <p style="margin:0 0 6px;font-weight:700;color:#1a2332;font-family:'Inter',sans-serif">Amende — {mat_nom}</p>
  82            <p style="margin:0 0 4px;color:#4a5a6a;font-size:13px;font-family:'Inter',sans-serif">Montant : <strong style="color:#16a34a">{montant}€</strong></p>
  83            <p style="margin:0 0 4px;color:#4a5a6a;font-size:13px;font-family:'Inter',sans-serif">Carte : •••• {card_last4}</p>
  84            <p style="margin:0;color:#8a9aaa;font-size:12px;font-family:'Inter',sans-serif">Réf : {ref}</p>
  85        </div>
  86        <p style="color:#16a34a;font-size:13px;margin:0;font-family:'Inter',sans-serif">✅ Votre amende est maintenant soldée. Merci !</p>"""))
  87
  88def email_retard(user_email, user_name, mat_nom, date_retour, amende, emp_id):
  89    send_email(user_email, f"⚠️ LoanTrack — Retard de retour : {mat_nom}", email_html("#dc2626",
  90        f"""<h3 style="margin:0 0 8px;color:#1a2332;font-family:'Inter',sans-serif">Bonjour {user_name},</h3>
  91        <p style="color:#dc2626;font-weight:700;font-size:1rem;margin-bottom:16px;font-family:'Inter',sans-serif">⚠️ Votre emprunt est en retard.</p>
  92        <div style="background:#fff1f2;border-radius:10px;padding:18px;margin-bottom:18px;border-left:4px solid #dc2626">
  93            <p style="margin:0 0 6px;font-weight:700;color:#1a2332;font-family:'Inter',sans-serif">{mat_nom}</p>
  94            <p style="margin:0 0 4px;color:#dc2626;font-size:13px;font-family:'Inter',sans-serif">Date limite dépassée : <strong>{date_retour}</strong></p>
  95            <p style="margin:0;color:#dc2626;font-size:13px;font-family:'Inter',sans-serif">Amende actuelle : <strong>{amende}€</strong></p>
  96        </div>
  97        <div style="background:#1a2332;border-radius:10px;padding:18px;margin-bottom:16px">
  98            <p style="color:#f87171;font-size:0.85rem;font-weight:700;margin:0 0 10px;font-family:'Inter',sans-serif;text-transform:uppercase;letter-spacing:0.05em">⏱ Chaque jour de retard supplémentaire = +1€</p>
  99            <table style="width:100%;border-collapse:collapse">
 100                <tr><td style="color:#8a9aaa;font-size:12px;font-family:'Inter',sans-serif;padding:4px 0">Réf. emprunt</td><td style="color:#f1f5f9;font-size:12px;font-family:monospace;text-align:right">{emp_id}</td></tr>
 101                <tr><td style="color:#8a9aaa;font-size:12px;font-family:'Inter',sans-serif;padding:4px 0">Amende en cours</td><td style="color:#f87171;font-size:14px;font-weight:700;font-family:monospace;text-align:right">{amende}€</td></tr>
 102            </table>
 103        </div>
 104        <p style="color:#4a5a6a;font-size:13px;margin:0 0 10px;font-family:'Inter',sans-serif">📌 Pour éviter que l'amende n'augmente davantage, rendez le matériel <strong>dès que possible</strong> au casier indiqué et déclarez le retour dans l'application.</p>
 105        <p style="color:#4a5a6a;font-size:13px;margin:0;font-family:'Inter',sans-serif">En cas de problème, contactez directement votre responsable via la messagerie LoanTrack. L'amende pourra être contestée dans un délai de 48h après le retour.</p>"""))
 106
 107def email_reservation_dispo(user_email, user_name, mat_nom):
 108    send_email(user_email, "🔔 LoanTrack — Votre réservation est disponible", email_html("#8b7355",
 109        f"""<h3 style="margin:0 0 16px">Bonjour {user_name},</h3>
 110        <p><strong>{mat_nom}</strong> est maintenant disponible !</p>
 111        <div style="background:#f5f0e8;border-radius:8px;padding:16px;border-left:3px solid #8b7355;margin:16px 0">
 112            <p style="margin:0;color:#6b6055">Connectez-vous rapidement avant que quelqu'un d'autre ne l'emprunte.</p>
 113        </div>"""))
 114
 115def email_retour_a_verifier(resp_email, user_name, mat_nom, emp_id):
 116    send_email(resp_email, "📬 LoanTrack — Retour à inspecter", email_html("#7a6b8a",
 117        f"""<h3 style="margin:0 0 16px">Action requise</h3>
 118        <p><strong>{user_name}</strong> a déclaré le retour de :</p>
 119        <div style="background:#f5f0e8;border-radius:8px;padding:16px;margin:16px 0">
 120            <p style="margin:0 0 6px;font-weight:600">{mat_nom}</p>
 121            <p style="margin:0;color:#6b6055;font-size:13px">Réf : {emp_id}</p>
 122        </div>
 123        <p style="color:#6b6055">Connectez-vous sur LoanTrack pour inspecter et valider le retour.</p>"""))
 124
 125app = Flask(__name__)
 126app.secret_key = 'loantrack-inraci-2026'
 127
 128@app.context_processor
 129def inject_unread():
 130    if 'user' in session:
 131        email = session.get('user')
 132        count = sum(1 for m in MESSAGES if m['a'] == email and not m['lu'])
 133        return {'unread_count': count}
 134    return {'unread_count': 0}
 135
 136if not firebase_admin._apps:
 137    cred = credentials.Certificate({
 138        "type": "service_account", "project_id": "loantrack-d9a48",
 139        "private_key_id": "ce0487248e774c757a0c4259ea6fa75ec0e7ae1b",
 140        "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCzf35q8JznfpQ/\ng0ok5EcwyWGAU9QgV/T1ug0YmFwJr7eu8BrYW9QSYjE3gcSn/YU57azfCLdRq6Iu\ntVfhFFvwmoUniVehiM3t49lTV4yhEWAlJ7ueHSO042yXV70O6GLtpqAmLUrjSqLY\ndVosrXumSM8NjrcTo2cdM6+gfU+gNPP0+LfE88BkKub5YE40HJqUZbadwa0p7bnk\nmdYZMe+66VhOYsITT5PyJQ52v4EA3HpZd8HTCkoUNp+r9DSTDGtQjfz3fCcHKHtR\nFQrFHOD5jzq6iCySkKLQlwKexWlVQlLMeIlpRvcYlKsJEPzGxnM/7uAYXLo6RtGU\nRHNufhDDAgMBAAECgf8zPkgfVoNMc6nnkcX5Wq3eWB39sU/INs+8N2Kifu3Y2PPb\nAashGUnjcVikQjKKjEGr8yFuOk+AGcYVAp8Sh1/rwgj2rGW4aM02PeLf48yclnd+\nv0q4KAPofxulJ4J58WIqNDgLWqwqaCuClLBb/L2WkXqFFTe2AE2nzZPhfhiMZbr1\ncyxNp7WELcg4mi/RB4BG2LSSCOXrClNN1SOuEnTaCBPfQBVhn9iHeqp4eN8An1bq\nowZme5Npz/Kx0NNyBhOG+B5J03Ie7dbSM7/Hycl61R6uQBfeGEmIh7kHwcMUWgoJ\ngwZLAm8CpdFtmh4Fz4OnQrCfhUkRM7g/35/TWcECgYEA4/maZVUb6CJd4NBC++kv\nrYPxmVDuud0gewnkrCQkE79XuI7HaAIDVzp32m+F+KdM78Ka2em2jAzFPhN4OxzL\nm72SgqXuJu+tTjGo+/f+9eywTMFGf63Jsxar8F1qoHQhVIiazBfCSqdOiibFUQPs\n3GvCzZxBG9tHAdJ/0+9Ol8ECgYEAyZBQnc4EZKyJhhaHjga/2fJoIkE9B7vtKFD+\nbdgYGzixiN19yuWDNfsKK67vU+qdhA6muABiF8YWmN5dQ0Rm+HcObiPwGtgpHl99\nMCOSeZGoOlwLqYdfYFTMovUSlTmtmEkthGIG/7FHei0KI6jP1jjUlCRZ4zHSVSvI\n9h0YqYMCgYEAihB+KuRSVFGL3T0DdCCS2VjRFnLnQPTkWN9y97Wji7oT3BfMN/cQ\nIuGr+EQWNLLNa17F/TcHcOXDBocwB0y3GojClBD9m+MHaBW3K3HTnQhM9Q8QLP8I\n6kbOtnE8xBPQo6tfZD73UdLlQ9GbKidgaVVx0qfQ3pf79uSOucFyLYECgYBw0wMN\nEeCLqbhhAgNmkIibKoD3i6Tpy1t4kb2ZJrh3pEhb3/8lr0q+0IJk6Uq1okIBRWI0\n5KIDxLGpZ+60VHl+4sWFCUDBBfYeNj0Q0RiQS+PqptMwVcIhXdYd8Sgxt1NgRrXf\nwC9CuKzVypg+VaPguXbkZWSbM6wUWKnoeWjwmQKBgA/l/h0OR5aEeEzM/4Dnc0wc\nZDaXWePqwhPoCbhJyTVwXU/K+AEL8cgXIst3Wv/2z+bzv6dwTIdZ+pA3Iv5njqMS\nxM/lSnSX3ZYy/rTL+jOhnRJZdmziUrURj7QqyUknP2hO71DtnsDWHyEn6ZXURmGV\nCFm4DAvooo1RJpFZc9NB\n-----END PRIVATE KEY-----\n",
 141        "client_email": "firebase-adminsdk-fbsvc@loantrack-d9a48.iam.gserviceaccount.com",
 142        "client_id": "108763509312824605172",
 143        "auth_uri": "https://accounts.google.com/o/oauth2/auth",
 144        "token_uri": "https://oauth2.googleapis.com/token",
 145        "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
 146        "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40loantrack-d9a48.iam.gserviceaccount.com",
 147        "universe_domain": "googleapis.com"
 148    })
 149    firebase_admin.initialize_app(cred)
 150
 151# ─── DATA ────────────────────────────────────────────────────
 152UTILISATEURS = {
 153    'enes.atmac@inraci.be': {'email':'enes.atmac@inraci.be','password':hashlib.sha256('Azerty'.encode()).hexdigest(),'nom':'Atmac','prenom':'Enes','role':'admin','blacklist':False,'mdp_clair':'Azerty'},
 154    'atmacenes@gmail.com':  {'email':'atmacenes@gmail.com', 'password':hashlib.sha256('Erenenes2'.encode()).hexdigest(),'nom':'Atmac','prenom':'Enes','role':'responsable','blacklist':False},
 155    'eleve@test.com':       {'email':'eleve@test.com',      'password':hashlib.sha256('eleve123'.encode()).hexdigest(),'nom':'Martin','prenom':'Jean','role':'eleve','blacklist':False},
 156}
 157
 158# 2 catégories seulement : Informatique & Scolaire
 159MATERIEL = {
 160    'M1':  {'id':'M1',  'nom':'MacBook Pro 14"',      'emoji':'💻','etat':'Neuf','dispo':True, 'casier':'A01','categorie':'Informatique','description':'Apple M3 Pro · 16 Go RAM · 512 Go SSD. Idéal pour le développement, la PAO et les présentations professionnelles.','duree_max':7,'quantite':2,'stock':2},
 161    'M2':  {'id':'M2',  'nom':'Dell XPS 15',           'emoji':'🖥️','etat':'Bon', 'dispo':True, 'casier':'A02','categorie':'Informatique','description':'Intel i7 · 16 Go RAM · écran 4K OLED. Laptop polyvalent pour tous travaux informatiques.','duree_max':7,'quantite':2,'stock':2},
 162    'M3':  {'id':'M3',  'nom':'iPad Pro 12.9"',        'emoji':'📱','etat':'Bon', 'dispo':True, 'casier':'A03','categorie':'Informatique','description':'Apple M2 · écran Liquid Retina. Parfait pour les présentations, la prise de notes et la lecture.','duree_max':5,'quantite':3,'stock':3},
 163    'M4':  {'id':'M4',  'nom':'Raspberry Pi 4',        'emoji':'🍓','etat':'Neuf','dispo':True, 'casier':'A04','categorie':'Informatique','description':'Mini-ordinateur 4 Go RAM. Pour les projets IoT, serveur local et apprentissage de la programmation.','duree_max':7,'quantite':5,'stock':5},
 164    'M5':  {'id':'M5',  'nom':'Arduino Uno',           'emoji':'🔌','etat':'Usé', 'dispo':True, 'casier':'A05','categorie':'Informatique','description':'Microcontrôleur open-source. Inclus câble USB, breadboard et documentation complète.','duree_max':7,'quantite':4,'stock':4},
 165    'M6':  {'id':'M6',  'nom':'Sony Alpha 7',          'emoji':'📷','etat':'Bon', 'dispo':False,'casier':'A06','categorie':'Informatique','description':'Hybride plein format 24 MP avec objectif 28-70 mm. Pour projets photo et vidéo professionnels.','duree_max':3,'quantite':1,'stock':0},
 166    'M7':  {'id':'M7',  'nom':'Casque Sony XM5',       'emoji':'🎧','etat':'Bon', 'dispo':True, 'casier':'A07','categorie':'Informatique','description':'Réduction de bruit active · autonomie 30 h. Idéal pour travailler en environnement bruyant.','duree_max':3,'quantite':3,'stock':3},
 167    'M8':  {'id':'M8',  'nom':'Disque SSD 1 To',       'emoji':'💾','etat':'Neuf','dispo':True, 'casier':'A08','categorie':'Informatique','description':'USB 3.2 · jusqu\'à 1 000 Mo/s. Pour sauvegardes et transferts rapides de gros fichiers.','duree_max':3,'quantite':4,'stock':4},
 168    'M9':  {'id':'M9',  'nom':'Tablette graphique',    'emoji':'🖊️','etat':'Neuf','dispo':True, 'casier':'A09','categorie':'Informatique','description':'Wacom Intuos S · surface 15×10 cm. Pour illustration numérique et PAO.','duree_max':5,'quantite':2,'stock':2},
 169    'M10': {'id':'M10', 'nom':'Webcam Logitech C920',  'emoji':'📹','etat':'Bon', 'dispo':True, 'casier':'A10','categorie':'Informatique','description':'Full HD 1080p avec micro intégré. Pour visioconférences, tutoriels et enregistrements.','duree_max':3,'quantite':3,'stock':3},
 170    'M11': {'id':'M11', 'nom':'Calculatrice TI-84',    'emoji':'🔢','etat':'Bon', 'dispo':True, 'casier':'B01','categorie':'Scolaire','description':'Calculatrice graphique Texas Instruments. Indispensable pour les cours de maths, physique et statistiques.','duree_max':14,'quantite':6,'stock':6},
 171    'M12': {'id':'M12', 'nom':'Calculatrice Casio FX', 'emoji':'🧮','etat':'Neuf','dispo':True, 'casier':'B02','categorie':'Scolaire','description':'Calculatrice scientifique Casio FX-991. Autorisée aux examens officiels. Fonctions avancées incluses.','duree_max':14,'quantite':6,'stock':6},
 172    'M13': {'id':'M13', 'nom':'Set de géométrie',      'emoji':'📐','etat':'Bon', 'dispo':True, 'casier':'B03','categorie':'Scolaire','description':'Règle 30 cm, équerre 45°/60°, rapporteur 180° et compas de précision. Pour dessin technique.','duree_max':5,'quantite':5,'stock':5},
 173    'M14': {'id':'M14', 'nom':'Dictionnaire Larousse', 'emoji':'📚','etat':'Bon', 'dispo':True, 'casier':'B04','categorie':'Scolaire','description':'Dictionnaire Larousse 2024 édition complète. Définitions, étymologie, conjugaisons et encyclopédie.','duree_max':7,'quantite':4,'stock':4},
 174    'M15': {'id':'M15', 'nom':'Atlas géographique',    'emoji':'🗺️','etat':'Bon', 'dispo':True, 'casier':'B05','categorie':'Scolaire','description':'Atlas mondial 2023 avec cartes physiques, politiques et thématiques. Indispensable en géographie.','duree_max':7,'quantite':3,'stock':3},
 175    'M16': {'id':'M16', 'nom':'Microscope optique',    'emoji':'🔬','etat':'Neuf','dispo':True, 'casier':'B06','categorie':'Scolaire','description':'Binoculaire 40×–1000×. Avec jeu de lames préparées pour TP de biologie et sciences naturelles.','duree_max':3,'quantite':2,'stock':2},
 176    'M17': {'id':'M17', 'nom':'Kit sciences physiques','emoji':'⚡','etat':'Bon', 'dispo':True, 'casier':'B07','categorie':'Scolaire','description':'Multimètre, fils, résistances, LED, breadboard. Complet pour les TP d\'électronique et de physique.','duree_max':5,'quantite':3,'stock':3},
 177    'M18': {'id':'M18', 'nom':'Trousse arts plastiques','emoji':'✏️','etat':'Bon','dispo':True, 'casier':'B08','categorie':'Scolaire','description':'Crayons HB/2B/4B, gomme, taille-crayon, stylos feutres, fusains. Pour les cours d\'arts plastiques.','duree_max':3,'quantite':4,'stock':4},
 178}
 179
 180EMPRUNTS = {
 181    'EMP-0001': {
 182        'id':'EMP-0001','user_email':'eleve@test.com','user_name':'Jean Martin',
 183        'mat_id':'M6','mat_nom':'Sony Alpha 7','mat_emoji':'📷','casier':'A06',
 184        'code_unique':'847291','date_debut':'01/04/2026 09:00','date_retour':'03/04/2026 16:00',
 185        'statut':'en_retard','rendu':False,'en_attente_retour':False,
 186        'amende':0,'amende_figee':False,'date_prolonge':None,
 187        'note_retour':'','inspecte_par':None,'amende_payee':False,
 188    },
 189}
 190
 191RESERVATIONS = {}  # file d'attente: {mat_id: [email1, email2, ...]}
 192CRENEAUX = {}      # réservations avec créneau: {mat_id: {email: {'datetime': '...', 'date_retour': '...'}}}
 193LOGS = []
 194MESSAGES = []      # [{id, de, a, sujet, contenu, date, lu, replies:[{de,contenu,date}]}]
 195
 196def add_log(action, detail='', user=None):
 197    LOGS.insert(0, {'datetime':now_local().strftime("%d/%m/%Y %H:%M:%S"),
 198                    'user':user or session.get('user','système'),'action':action,'detail':detail})
 199    if len(LOGS) > 300: LOGS.pop()
 200
 201def auto_emoji(nom):
 202    """Auto-detect emoji based on equipment name keywords."""
 203    nom_lower = nom.lower()
 204    mapping = [
 205        (['macbook','mac','apple','imac'], '💻'),
 206        (['laptop','ordinateur portable','notebook','dell','hp','lenovo','thinkpad','xps','asus'], '💻'),
 207        (['pc','desktop','ordinateur','computer'], '🖥️'),
 208        (['ipad','tablette graphique','wacom'], '🎨'),
 209        (['tablette','tablet'], '📱'),
 210        (['iphone','smartphone','téléphone','phone','mobile'], '📱'),
 211        (['raspberry','arduino','microcontrôleur','esp32'], '🔌'),
 212        (['casque','écouteurs','headphone','airpod'], '🎧'),
 213        (['webcam','caméra','camera'], '📹'),
 214        (['appareil photo','reflex','hybride','sony alpha','canon eos','nikon'], '📷'),
 215        (['projecteur','beamer','vidéoprojecteur'], '📽️'),
 216        (['disque dur','ssd','hdd','clé usb','stockage'], '💾'),
 217        (['calculatrice','calculette','ti-','casio'], '🔢'),
 218        (['microscope'], '🔬'),
 219        (['atlas','carte géograph'], '🗺️'),
 220        (['dictionnaire','encyclopédie','larousse'], '📚'),
 221        (['livre','manuel','textbook'], '📖'),
 222        (['trousse','crayon','feutre','pinceau','art'], '✏️'),
 223        (['règle','équerre','compas','rapporteur','géométrie'], '📐'),
 224        (['kit','laboratoire','chimie','physique','sciences'], '⚡'),
 225        (['imprimante','scanner','copier'], '🖨️'),
 226        (['souris','mouse'], '🖱️'),
 227        (['clavier','keyboard'], '⌨️'),
 228        (['câble','chargeur','adaptateur'], '🔋'),
 229        (['montre','watch'], '⌚'),
 230        (['livre audio','audio'], '🔊'),
 231    ]
 232    for keywords, emoji in mapping:
 233        if any(k in nom_lower for k in keywords):
 234            return emoji
 235    return '📦'
 236
 237def hash_mdp(mdp): return hashlib.sha256(mdp.encode()).hexdigest()
 238def generate_code(): return ''.join(random.choices(string.digits, k=6))
 239
 240def get_role(email):
 241    if email == 'enes.atmac@inraci.be': return 'admin'
 242    if email == 'atmacenes@gmail.com':  return 'responsable'
 243    return 'eleve'
 244
 245def calc_amende(emp):
 246    if emp.get('rendu'): return emp.get('amende',0)
 247    if emp.get('amende_figee'): return emp.get('amende',0)
 248    try:
 249        retour = datetime.strptime(emp['date_retour'], "%d/%m/%Y %H:%M")
 250        delta = (now_local() - retour).total_seconds()
 251        if delta > 0:
 252            # 1€ minimum dès la 1ère minute, puis +1€ par jour complet supplémentaire
 253            jours_sup = int(delta // 86400)
 254            return 1 + jours_sup
 255    except: pass
 256    return 0
 257
 258def now_local():
 259    """Return current datetime in Europe/Brussels timezone (UTC+1 or UTC+2 depending on DST)."""
 260    try:
 261        from zoneinfo import ZoneInfo
 262        from datetime import timezone
 263        tz = ZoneInfo("Europe/Brussels")
 264        return datetime.now(tz).replace(tzinfo=None)
 265    except Exception:
 266        # Fallback: assume UTC+2 (CEST, Belgian summer time)
 267        return datetime.utcnow() + __import__('datetime').timedelta(hours=2)
 268
 269def heures_restantes(emp):
 270    """Returns hours remaining. Stores minutes_restantes and total_minutes_restantes on emp dict."""
 271    try:
 272        retour = datetime.strptime(emp['date_retour'], "%d/%m/%Y %H:%M")
 273        diff_sec = (retour - now_local()).total_seconds()
 274        if diff_sec <= 0:
 275            emp['minutes_restantes'] = 0
 276            emp['total_minutes_restantes'] = 0
 277            return int(diff_sec // 3600)  # negative = overdue
 278        total_minutes = max(1, int(diff_sec // 60))
 279        hours = total_minutes // 60
 280        minutes = total_minutes % 60
 281        emp['minutes_restantes'] = minutes
 282        emp['total_minutes_restantes'] = total_minutes
 283        return hours
 284    except:
 285        emp['minutes_restantes'] = 0
 286        emp['total_minutes_restantes'] = 9999
 287        return None
 288
 289def get_date_retour_prevu(mat_id):
 290    for e in EMPRUNTS.values():
 291        if e['mat_id'] == mat_id and not e['rendu']:
 292            return e['date_retour']
 293    return None
 294
 295def get_resp_emails():
 296    return [u['email'] for u in UTILISATEURS.values() if u['role'] in ('responsable','admin')]
 297
 298def update_retards_and_notify():
 299    """Appele a chaque chargement - met a jour les statuts et envoie emails si nouveau retard detecte."""
 300    for emp in EMPRUNTS.values():
 301        if emp.get('rendu') or emp.get('en_attente_retour'): continue
 302        try:
 303            retour = datetime.strptime(emp['date_retour'], "%d/%m/%Y %H:%M")
 304            delta = (now_local() - retour).total_seconds()
 305            if delta > 0:
 306                emp['statut'] = 'en_retard'
 307                new_amende = calc_amende(emp)
 308                # Send email only once when first going overdue (amende was 0 before)
 309                if emp.get('amende', 0) == 0 and new_amende > 0:
 310                    user = UTILISATEURS.get(emp['user_email'], {})
 311                    user_name = f"{user.get('prenom','')} {user.get('nom','')}"
 312                    email_retard(emp['user_email'], user_name, emp['mat_nom'],
 313                                 emp['date_retour'], new_amende, emp['id'])
 314                emp['amende'] = new_amende
 315            elif emp['statut'] == 'en_retard':
 316                emp['statut'] = 'en_cours'
 317        except: pass
 318
 319def get_admin_stats():
 320    now = now_local()
 321    tous = list(EMPRUNTS.values())
 322    actifs   = [e for e in tous if not e['rendu']]
 323    termines = [e for e in tous if e['rendu']]
 324    ce_mois  = [e for e in tous if e['date_debut'].split('/')[1]+'/'+e['date_debut'].split('/')[2][:4] == f"{now.month:02d}/{now.year}"]
 325    for e in actifs: e['amende'] = calc_amende(e)
 326    compteur = {}
 327    for e in tous: compteur[e['mat_nom']] = compteur.get(e['mat_nom'],0)+1
 328    en_attente = [e for e in actifs if e.get('en_attente_retour')]
 329    return {
 330        'actifs':len(actifs),'termines':len(termines),'ce_mois':len(ce_mois),
 331        'amendes_actives':sum(e['amende'] for e in actifs),
 332        'amendes_soldees':sum(e.get('amende',0) for e in termines),
 333        'top_mat':sorted(compteur.items(),key=lambda x:x[1],reverse=True)[:3],
 334        'en_retard':len([e for e in actifs if e['statut']=='en_retard']),
 335        'en_attente_retour':len(en_attente),
 336        'nb_users':len(UTILISATEURS),'nb_materiel':len(MATERIEL),
 337    }
 338
 339def login_required(f):
 340    @wraps(f)
 341    def wrapper(*args, **kwargs):
 342        if 'user' not in session: return redirect(url_for('login'))
 343        return f(*args, **kwargs)
 344    return wrapper
 345
 346def role_required(*roles):
 347    def decorator(f):
 348        @wraps(f)
 349        def wrapper(*args, **kwargs):
 350            if session.get('role') not in roles:
 351                flash("Accès refusé.", "danger"); return redirect(url_for('index'))
 352            return f(*args, **kwargs)
 353        return wrapper
 354    return decorator
 355
 356# ─── Auth ────────────────────────────────────────────────────
 357@app.route('/login', methods=['GET','POST'])
 358def login():
 359    if session.get('user'): return redirect(url_for('index'))
 360    if request.method == 'POST':
 361        email = request.form.get('email','').strip().lower()
 362        mdp   = request.form.get('mdp','')
 363        user  = UTILISATEURS.get(email)
 364        if user and user['password'] == hash_mdp(mdp):
 365            if user.get('blacklist'):
 366                flash("Votre compte est suspendu.", "danger")
 367                return render_template('index.html', view='login')
 368            session.update({'user':email,'role':user['role'],'prenom':user['prenom'],'nom':user['nom']})
 369            add_log('🔑 Connexion', f"{user['prenom']} {user['nom']}", email)
 370            flash(f"Bienvenue, {user['prenom']} !", "success")
 371            return redirect(url_for('index'))
 372        flash("Email ou mot de passe incorrect.", "danger")
 373    return render_template('index.html', view='login')
 374
 375@app.route('/forgot-password', methods=['GET','POST'])
 376def forgot_password():
 377    if request.method == 'POST':
 378        email = request.form.get('email','').strip().lower()
 379        if email in UTILISATEURS:
 380            new_mdp = ''.join(random.choices(string.ascii_letters+string.digits, k=8))
 381            UTILISATEURS[email]['password'] = hash_mdp(new_mdp)
 382            UTILISATEURS[email]['mdp_clair'] = new_mdp
 383            email_changement_mdp(email, UTILISATEURS[email].get('prenom',''), new_mdp)
 384            flash("Un nouveau mot de passe vous a été envoyé par email.", "success")
 385        else:
 386            flash("Aucun compte trouvé.", "danger")
 387        return redirect(url_for('login'))
 388    return render_template('index.html', view='forgot')
 389
 390@app.route('/google-login', methods=['POST'])
 391def google_login():
 392    token = request.form.get('id_token','')
 393    try:
 394        decoded = firebase_auth.verify_id_token(token)
 395        email   = decoded.get('email','').lower()
 396        name    = decoded.get('name','')
 397        if email not in UTILISATEURS:
 398            parts = name.split(' ',1)
 399            UTILISATEURS[email] = {'email':email,'password':'','nom':parts[1] if len(parts)>1 else '','prenom':parts[0] if parts else 'U','role':get_role(email),'blacklist':False}
 400            add_log('👤 Inscription Google', email, email)
 401        user = UTILISATEURS[email]
 402        if user.get('blacklist'):
 403            flash("Compte suspendu.", "danger"); return redirect(url_for('login'))
 404        session.update({'user':email,'role':user['role'],'prenom':user['prenom'],'nom':user['nom']})
 405        add_log('🔑 Google', f"{user['prenom']} {user['nom']}", email)
 406        flash(f"Bienvenue, {user['prenom']} !", "success")
 407        return redirect(url_for('index'))
 408    except Exception as e:
 409        flash(f"Erreur Google : {str(e)}", "danger")
 410        return redirect(url_for('login'))
 411
 412@app.route('/register', methods=['POST'])
 413def register():
 414    email=request.form.get('email','').strip().lower()
 415    mdp=request.form.get('mdp',''); mdp_conf=request.form.get('mdp_confirm','')
 416    nom=request.form.get('nom','').strip(); prenom=request.form.get('prenom','').strip()
 417    if not all([email,mdp,nom,prenom]):
 418        flash("Remplissez tous les champs.", "danger"); return render_template('index.html', view='register')
 419    if len(mdp)<6:
 420        flash("Mot de passe : 6 caractères min.", "danger"); return render_template('index.html', view='register')
 421    if mdp != mdp_conf:
 422        flash("Les mots de passe ne correspondent pas.", "danger"); return render_template('index.html', view='register')
 423    if email in UTILISATEURS:
 424        flash("Email déjà utilisé.", "danger"); return render_template('index.html', view='register')
 425    UTILISATEURS[email]={'email':email,'password':hash_mdp(mdp),'nom':nom,'prenom':prenom,'role':'eleve','blacklist':False,'mdp_clair':mdp}
 426    session.update({'user':email,'role':'eleve','prenom':prenom,'nom':nom})
 427    add_log('👤 Inscription', email, email)
 428    flash("Compte créé !", "success")
 429    return redirect(url_for('index'))
 430
 431@app.route('/logout')
 432def logout():
 433    if session.get('user'):
 434        add_log('🚪 Déconnexion', f"{session.get('prenom','')} {session.get('nom','')}", session.get('user'))
 435    session.clear()
 436    return redirect(url_for('login'))
 437
 438# ─── Profil ──────────────────────────────────────────────────
 439@app.route('/profil')
 440@login_required
 441def profil():
 442    email = session['user']
 443    user  = UTILISATEURS.get(email,{})
 444    mes   = [e for e in EMPRUNTS.values() if e['user_email']==email]
 445    for e in mes:
 446        e['amende'] = calc_amende(e)
 447        e['heures_restantes'] = heures_restantes(e)
 448    mes_resa = {mat_id: RESERVATIONS[mat_id].index(email)+1
 449                for mat_id, q in RESERVATIONS.items() if email in q}
 450    mes_creneaux = {mat_id: info[email] for mat_id, info in CRENEAUX.items() if email in info}
 451    return render_template('index.html', view='profil', user=user, emprunts=mes,
 452                           mes_resa=mes_resa, mes_creneaux=mes_creneaux, materiel=MATERIEL,
 453                           mdp_clair=user.get('mdp_clair',''))
 454
 455@app.route('/eleve/<path:email>')
 456@login_required
 457@role_required('admin','responsable')
 458def voir_eleve(email):
 459    user = UTILISATEURS.get(email)
 460    if not user:
 461        flash("Introuvable.", "danger"); return redirect(url_for('index'))
 462    emprunts = [e for e in EMPRUNTS.values() if e['user_email']==email]
 463    for e in emprunts:
 464        e['amende'] = calc_amende(e)
 465        e['heures_restantes'] = heures_restantes(e)
 466    return render_template('index.html', view='profil_eleve', user=user, emprunts=emprunts)
 467
 468# ─── Dashboard ───────────────────────────────────────────────
 469@app.route('/')
 470@login_required
 471def index():
 472    role  = session.get('role')
 473    email = session['user']
 474
 475    if role == 'admin':
 476        actifs = [e for e in EMPRUNTS.values() if not e['rendu']]
 477        for e in actifs: e['amende'] = calc_amende(e)
 478        stats = get_admin_stats()
 479        return render_template('index.html', view='admin_system',
 480                               actifs=actifs, users=list(UTILISATEURS.values()),
 481                               materiel=MATERIEL, stats=stats, logs=LOGS[:20])
 482
 483    if role == 'responsable':
 484        actifs = [e for e in EMPRUNTS.values() if not e['rendu']]
 485        for e in actifs: e['amende'] = calc_amende(e)
 486        en_attente_retour = [e for e in actifs if e.get('en_attente_retour')]
 487        # Enrichir avec les réservations
 488        resa_info = {}
 489        for mat_id, queue in RESERVATIONS.items():
 490            if queue:
 491                resa_info[mat_id] = [{'email':em,'nom':UTILISATEURS.get(em,{}).get('prenom','?')+' '+UTILISATEURS.get(em,{}).get('nom',''),'pos':i+1} for i,em in enumerate(queue)]
 492        return render_template('index.html', view='resp_emprunts',
 493                               actifs=actifs, materiel=MATERIEL,
 494                               users=list(UTILISATEURS.values()),
 495                               en_attente_retour=en_attente_retour,
 496                               resa_info=resa_info, creneaux=CRENEAUX)
 497
 498    # Élève
 499    update_retards_and_notify()
 500    mes = [e for e in EMPRUNTS.values() if e['user_email']==email]
 501    for e in mes:
 502        e['amende'] = calc_amende(e)
 503        e['heures_restantes'] = heures_restantes(e)
 504    mes_emprunts_actifs = [e for e in mes if not e['rendu']]
 505    mon_emprunt_actif = mes_emprunts_actifs[0] if mes_emprunts_actifs else None
 506    mes_resa = {mat_id: RESERVATIONS[mat_id].index(email)+1
 507                for mat_id, q in RESERVATIONS.items() if email in q}
 508    items_enrichis = {}
 509    for m_id, m in MATERIEL.items():
 510        mc = dict(m)
 511        if not m['dispo']:
 512            mc['date_retour_prevu'] = get_date_retour_prevu(m_id)
 513        mc['nb_resa']            = len(RESERVATIONS.get(m_id,[]))
 514        mc['est_reserve_par_moi']= email in RESERVATIONS.get(m_id,[])
 515        mc['ma_position']        = (RESERVATIONS[m_id].index(email)+1) if email in RESERVATIONS.get(m_id,[]) else None
 516        # Créneau
 517        creneau_mat = CRENEAUX.get(m_id, {})
 518        mc['mon_creneau'] = creneau_mat.get(email)
 519        mc['nb_creneaux'] = len(creneau_mat)
 520        items_enrichis[m_id] = mc
 521    return render_template('index.html', view='dashboard',
 522                           emprunts=mes, items=items_enrichis,
 523                           mon_emprunt_actif=mon_emprunt_actif,
 524                           mes_emprunts_actifs=mes_emprunts_actifs,
 525                           mes_resa=mes_resa,
 526                           today=now_local().strftime('%Y-%m-%dT%H:%M'))
 527
 528# ─── Emprunt ─────────────────────────────────────────────────
 529@app.route('/emprunter/<mat_id>', methods=['POST'])
 530@login_required
 531def faire_emprunt(mat_id):
 532    user = UTILISATEURS.get(session['user'])
 533    if user.get('blacklist'):
 534        flash("Compte suspendu.", "danger"); return redirect(url_for('index'))
 535    mat = MATERIEL.get(mat_id)
 536    if not mat:
 537        flash("Matériel introuvable.", "danger"); return redirect(url_for('index'))
 538    # Stock-aware: dispo si stock > 0
 539    stock_actuel = mat.get('stock', 1 if mat.get('dispo') else 0)
 540    if stock_actuel <= 0:
 541        flash("Stock épuisé — ce matériel n'est plus disponible.", "danger"); return redirect(url_for('index'))
 542    deja = [e for e in EMPRUNTS.values() if e['user_email']==session['user'] and not e['rendu']]
 543    if len(deja) >= 3:
 544        flash("Maximum 3 emprunts simultanes. Rendez d'abord du materiel.", "danger")
 545        return redirect(url_for('index'))
 546    date_debut  = request.form.get('date_debut','')
 547    date_retour = request.form.get('date_retour','')
 548    try:
 549        dt_debut  = datetime.strptime(date_debut,  '%Y-%m-%dT%H:%M')
 550        dt_retour = datetime.strptime(date_retour, '%Y-%m-%dT%H:%M')
 551        if dt_retour <= dt_debut:
 552            flash("❌ La date de retour doit être après la date de début.", "danger")
 553            return redirect(url_for('index') + f'#fiche-{mat_id}')
 554        duree_max = mat.get('duree_max',7)
 555        delta_total = (dt_retour - dt_debut).total_seconds()
 556        if delta_total <= 0:
 557            flash("La date de retour doit être après la date de début.", "danger")
 558            return redirect(url_for('index'))
 559        if delta_total > duree_max * 86400:
 560            flash(f"Durée maximum : {duree_max} jours pour ce matériel.", "danger")
 561            return redirect(url_for('index') + f'#fiche-{mat_id}')
 562    except:
 563        flash("❌ Dates invalides, veuillez réessayer.", "danger")
 564        return redirect(url_for('index') + f'#fiche-{mat_id}')
 565    emp_id = f"EMP-{random.randint(1000,9999)}"
 566    code   = generate_code()
 567    EMPRUNTS[emp_id] = {
 568        'id':emp_id,'user_email':session['user'],
 569        'user_name':f"{user['prenom']} {user['nom']}",
 570        'mat_id':mat_id,'mat_nom':mat['nom'],'mat_emoji':mat.get('emoji','📦'),
 571        'casier':mat['casier'],'code_unique':code,
 572        'date_debut':dt_debut.strftime("%d/%m/%Y %H:%M"),
 573        'date_retour':dt_retour.strftime("%d/%m/%Y %H:%M"),
 574        'statut':'en_cours','rendu':False,'en_attente_retour':False,
 575        'amende':0,'amende_figee':False,'date_prolonge':None,
 576        'note_retour':'','inspecte_par':None,'amende_payee':False,
 577    }
 578    # Décrémenter le stock
 579    MATERIEL[mat_id]['stock'] = max(0, MATERIEL[mat_id].get('stock', 1) - 1)
 580    MATERIEL[mat_id]['dispo'] = MATERIEL[mat_id]['stock'] > 0
 581    if mat_id in RESERVATIONS and session['user'] in RESERVATIONS[mat_id]:
 582        RESERVATIONS[mat_id].remove(session['user'])
 583    add_log('📤 Emprunt', f"{user['prenom']} {user['nom']} → {mat['nom']}", session['user'])
 584    email_confirmation_emprunt(session['user'], f"{user['prenom']} {user['nom']}",
 585        mat['nom'], code, dt_debut.strftime("%d/%m/%Y %H:%M"), dt_retour.strftime("%d/%m/%Y %H:%M"))
 586    return redirect(url_for('voir_recu', emp_id=emp_id))
 587
 588@app.route('/recu/<emp_id>')
 589@login_required
 590def voir_recu(emp_id):
 591    emp = EMPRUNTS.get(emp_id)
 592    if not emp or emp['user_email'] != session['user']: return redirect(url_for('index'))
 593    return render_template('index.html', view='recu', e=emp)
 594
 595# ─── Déclarer retour ─────────────────────────────────────────
 596@app.route('/declarer-retour/<emp_id>', methods=['POST'])
 597@login_required
 598def declarer_retour(emp_id):
 599    emp = EMPRUNTS.get(emp_id)
 600    if not emp or emp['user_email'] != session['user'] or emp['rendu']:
 601        flash("Emprunt introuvable.", "danger"); return redirect(url_for('historique'))
 602    emp['en_attente_retour'] = True
 603    emp['statut'] = 'en_attente_verification'
 604    emp['amende'] = calc_amende(emp)
 605    emp['amende_figee'] = True
 606    add_log('📬 Retour déclaré', f"{session.get('prenom','')} → {emp['mat_nom']}", session['user'])
 607    user = UTILISATEURS.get(session['user'],{})
 608    for resp_email in get_resp_emails():
 609        email_retour_a_verifier(resp_email, f"{user.get('prenom','')} {user.get('nom','')}", emp['mat_nom'], emp_id)
 610    flash("✅ Retour déclaré ! Un responsable va vérifier le matériel.", "success")
 611    return redirect(url_for('historique'))
 612
 613# ─── Valider retour (responsable) ────────────────────────────
 614@app.route('/valider-retour/<emp_id>', methods=['POST'])
 615@login_required
 616@role_required('admin','responsable')
 617def valider_retour(emp_id):
 618    emp = EMPRUNTS.get(emp_id)
 619    if not emp or emp['rendu']:
 620        flash("Introuvable.", "danger"); return redirect(url_for('index'))
 621    etat_ok = request.form.get('etat_ok','oui')
 622    note    = request.form.get('note','').strip()
 623    mat_id  = emp['mat_id']
 624    emp['rendu']        = True
 625    emp['statut']       = 'terminé'
 626    emp['en_attente_retour'] = False
 627    emp['note_retour']  = note
 628    emp['inspecte_par'] = session.get('user')
 629    emp['date_rendu']   = now_local().strftime("%d/%m/%Y %H:%M")
 630    if etat_ok == 'oui':
 631        # Réincrémenter le stock
 632        MATERIEL[mat_id]['stock'] = min(
 633            MATERIEL[mat_id].get('quantite', 1),
 634            MATERIEL[mat_id].get('stock', 0) + 1
 635        )
 636        MATERIEL[mat_id]['dispo'] = MATERIEL[mat_id]['stock'] > 0
 637        add_log('✅ Retour validé', f"{emp['user_name']} → {emp['mat_nom']}", session.get('user'))
 638        flash("Retour validé, matériel remis en stock.", "success")
 639        if mat_id in RESERVATIONS and RESERVATIONS[mat_id]:
 640            next_email = RESERVATIONS[mat_id][0]
 641            next_user  = UTILISATEURS.get(next_email,{})
 642            email_reservation_dispo(next_email, f"{next_user.get('prenom','')} {next_user.get('nom','')}", emp['mat_nom'])
 643            add_log('🔔 Notif résa', f"{next_email} → {emp['mat_nom']}", 'système')
 644    else:
 645        MATERIEL[mat_id]['etat'] = 'Usé'
 646        MATERIEL[mat_id]['dispo'] = False
 647        add_log('⚠️ Retour problème', f"{emp['user_name']} → {emp['mat_nom']} (endommagé)", session.get('user'))
 648        flash("Retour enregistré avec problème. Matériel hors service.", "danger")
 649    return redirect(request.referrer or url_for('index'))
 650
 651
 652# ─── Ouvrir casier par numéro (responsable) ──────────────────
 653@app.route('/casier/<casier_id>')
 654@login_required
 655@role_required('admin','responsable')
 656def ouvrir_casier_direct(casier_id):
 657    """Retourne le code d'un casier à partir de son identifiant — pour le responsable."""
 658    # Find active emprunt for this casier
 659    emp = next((e for e in EMPRUNTS.values() if e['casier'] == casier_id and not e['rendu']), None)
 660    mat = next((m for m in MATERIEL.values() if m['casier'] == casier_id), None)
 661    add_log('🔑 Casier ouvert', f"Casier {casier_id}{' · ' + emp['user_name'] if emp else ' (libre)'}", session.get('user'))
 662    statut = f"occupe par {emp['user_name']}" if emp else "libre"
 663    flash(f"Casier {casier_id} ouvert ({statut}).", "success")
 664    return redirect(request.referrer or url_for('admin_materiel_page'))
 665
 666# ─── Ouvrir casier (responsable) ─────────────────────────────
 667@app.route('/ouvrir-casier/<emp_id>', methods=['GET'])
 668@login_required
 669@role_required('admin','responsable')
 670def ouvrir_casier_resp(emp_id):
 671    """Retourne le code du casier pour un emprunt — réservé au responsable/admin."""
 672    emp = EMPRUNTS.get(emp_id)
 673    if not emp:
 674        flash("Emprunt introuvable.", "danger")
 675        return redirect(url_for('index'))
 676    # Juste log l'action — le responsable ouvre physiquement, pas besoin d'afficher le code
 677    add_log('🔑 Casier ouvert', f"{emp['user_name']} · {emp['mat_nom']} · casier {emp['casier']}", session.get('user'))
 678    flash(f"Casier {emp['casier']} ouvert pour {emp['user_name']}.", "success")
 679    return redirect(request.referrer or url_for('index'))
 680
 681
 682# ─── Signaler problème retour (auto-message + cloture) ──────
 683@app.route('/signaler-probleme/<emp_id>', methods=['POST'])
 684@login_required
 685@role_required('admin','responsable')
 686def signaler_probleme(emp_id):
 687    emp = EMPRUNTS.get(emp_id)
 688    if not emp:
 689        flash("Emprunt introuvable.", "danger"); return redirect(url_for('index'))
 690    user_dest = UTILISATEURS.get(emp['user_email'], {})
 691    prenom = user_dest.get('prenom', '')
 692    from_name = f"{session.get('prenom','')} {session.get('nom','')}"
 693    note = request.form.get('note_probleme', '').strip()
 694    # ── Message automatique à l'élève ──
 695    sujet = f"Probleme lors du retour de {emp['mat_nom']}"
 696    mat_nom_safe = emp['mat_nom']
 697    note_ligne = ('Remarque : ' + note + '\n\n') if note else ''
 698    contenu = (
 699        "Bonjour " + prenom + ",\n\n"
 700        "Nous avons constate un probleme lors de la verification du retour de votre emprunt "
 701        "de " + mat_nom_safe + " (ref. " + emp_id + ").\n\n"
 702        + note_ligne +
 703        "Le materiel a ete signale comme endommage ou incomplet. "
 704        "Merci de contacter votre responsable des que possible pour regulariser la situation.\n\n"
 705        "Cordialement,\nLoanTrack INRACI"
 706    )
 707    msg_id = f"MSG-{__import__('random').randint(10000,99999)}"
 708    MESSAGES.insert(0, {
 709        'id': msg_id, 'de': session['user'], 'de_nom': from_name,
 710        'a': emp['user_email'],
 711        'a_nom': f"{user_dest.get('prenom','')} {user_dest.get('nom','')}",
 712        'sujet': sujet, 'contenu': contenu,
 713        'date': now_local().strftime("%d/%m/%Y %H:%M"),
 714        'lu': False, 'replies': []
 715    })
 716    email_nouveau_message(emp['user_email'], from_name, sujet, contenu)
 717    # ── Clôturer l'emprunt avec statut problème ──
 718    mat_id = emp['mat_id']
 719    emp['rendu'] = True
 720    emp['statut'] = 'probleme'
 721    emp['en_attente_retour'] = False
 722    emp['note_retour'] = note or 'Probleme signale par le responsable'
 723    emp['inspecte_par'] = session.get('user')
 724    emp['date_rendu'] = now_local().strftime("%d/%m/%Y %H:%M")
 725    MATERIEL[mat_id]['etat'] = 'Use'
 726    MATERIEL[mat_id]['dispo'] = False
 727    add_log('⚠ Retour probleme', f"{emp['user_name']} -> {emp['mat_nom']} | msg auto envoye", session.get('user'))
 728    flash(f"Probleme signale. Message envoye automatiquement a {prenom}. Materiel hors service.", "danger")
 729    return redirect(url_for('index'))
 730
 731# ─── Marquer amende payée ────────────────────────────────────
 732@app.route('/payer-amende/<emp_id>', methods=['POST'])
 733@login_required
 734@role_required('admin','responsable')
 735def payer_amende(emp_id):
 736    emp = EMPRUNTS.get(emp_id)
 737    if emp:
 738        emp['amende_payee'] = True
 739        add_log('💳 Amende payée', f"{emp['user_name']} · {emp['amende']}€ · {emp['mat_nom']}", session.get('user'))
 740        flash(f"Amende de {emp['amende']}€ marquée comme payée.", "success")
 741    return redirect(request.referrer or url_for('index'))
 742
 743# ─── Prolongement (max 1 jour) ───────────────────────────────
 744@app.route('/prolonger/<emp_id>', methods=['POST'])
 745@login_required
 746def prolonger_emprunt(emp_id):
 747    emp = EMPRUNTS.get(emp_id)
 748    if not emp or emp['user_email'] != session['user']:
 749        flash("Emprunt introuvable.", "danger"); return redirect(url_for('historique'))
 750    if emp.get('date_prolonge'):
 751        flash("Déjà prolongé une fois.", "danger"); return redirect(url_for('historique'))
 752    if emp.get('en_attente_retour'):
 753        flash("Retour déjà déclaré.", "danger"); return redirect(url_for('historique'))
 754    try:
 755        dt_old = datetime.strptime(emp['date_retour'], '%d/%m/%Y %H:%M')
 756        # Max 1 jour de prolongement
 757        dt_new = dt_old + timedelta(days=1)
 758        # Si amende en cours, on la fige (elle s'arrête)
 759        amende_actuelle = calc_amende(emp)
 760        if amende_actuelle > 0:
 761            emp['amende']       = amende_actuelle
 762            emp['amende_figee'] = True
 763        emp['date_prolonge'] = emp['date_retour']
 764        emp['date_retour']   = dt_new.strftime("%d/%m/%Y %H:%M")
 765        emp['statut']        = 'en_cours'
 766        add_log('📅 Prolongement +1j', f"{session.get('prenom','')} → {emp['mat_nom']}", session['user'])
 767        flash(f"Date prolongée de 1 jour. Nouveau retour : {emp['date_retour']}", "success")
 768    except Exception as ex:
 769        flash(f"Erreur : {ex}", "danger")
 770    return redirect(url_for('historique'))
 771
 772# ─── Réservation avec créneau ────────────────────────────────
 773@app.route('/reserver-creneau/<mat_id>', methods=['POST'])
 774@login_required
 775def reserver_creneau(mat_id):
 776    email = session['user']
 777    mat = MATERIEL.get(mat_id)
 778    if not mat:
 779        flash("Matériel introuvable.", "danger"); return redirect(url_for('index'))
 780    if email in CRENEAUX.get(mat_id, {}):
 781        flash("Vous avez déjà un créneau pour ce matériel.", "danger"); return redirect(url_for('index'))
 782    date_creneau = request.form.get('date_creneau','')
 783    date_retour_creneau = request.form.get('date_retour_creneau','')
 784    if not date_creneau or not date_retour_creneau:
 785        flash("❌ Veuillez renseigner toutes les dates.", "danger")
 786        return redirect(url_for('index') + f'#fiche-{mat_id}')
 787    try:
 788        dt_creneau = datetime.strptime(date_creneau, '%Y-%m-%dT%H:%M')
 789        dt_retour  = datetime.strptime(date_retour_creneau, '%Y-%m-%dT%H:%M')
 790        if dt_creneau < now_local():
 791            flash("❌ La date de retrait doit être dans le futur.", "danger")
 792            return redirect(url_for('index') + f'#fiche-{mat_id}')
 793        if dt_retour <= dt_creneau:
 794            flash("❌ La date de retour doit être après le retrait.", "danger")
 795            return redirect(url_for('index') + f'#fiche-{mat_id}')
 796        duree_max = mat.get('duree_max', 7)
 797        delta_cr = (dt_retour - dt_creneau).total_seconds()
 798        if delta_cr <= 0:
 799            flash("La date de retour doit être après la date de début.", "danger")
 800            return redirect(url_for('index'))
 801        if delta_cr > duree_max * 86400:
 802            flash(f"❌ Durée maximum : {duree_max} jours pour ce matériel.", "danger")
 803            return redirect(url_for('index') + f'#fiche-{mat_id}')
 804    except:
 805        flash("❌ Dates invalides, veuillez réessayer.", "danger")
 806        return redirect(url_for('index') + f'#fiche-{mat_id}')
 807    code_creneau = generate_code()
 808    if mat_id not in CRENEAUX: CRENEAUX[mat_id] = {}
 809    CRENEAUX[mat_id][email] = {
 810        'datetime':    dt_creneau.strftime("%d/%m/%Y %H:%M"),
 811        'date_retour': dt_retour.strftime("%d/%m/%Y %H:%M"),
 812        'user_name':   f"{session.get('prenom','')} {session.get('nom','')}",
 813        'mat_id':      mat_id,
 814        'mat_nom':     mat['nom'],
 815        'mat_emoji':   mat.get('emoji','📦'),
 816        'casier':      mat['casier'],
 817        'code':        code_creneau,
 818    }
 819    add_log('📅 Créneau', f"{session.get('prenom','')} → {mat['nom']} le {dt_creneau.strftime('%d/%m/%Y %H:%M')}", email)
 820    return redirect(url_for('recu_creneau', mat_id=mat_id))
 821
 822@app.route('/recu-creneau/<mat_id>')
 823@login_required
 824def recu_creneau(mat_id):
 825    email = session['user']
 826    cr = CRENEAUX.get(mat_id, {}).get(email)
 827    if not cr:
 828        flash("Créneau introuvable.", "danger"); return redirect(url_for('index'))
 829    return render_template('index.html', view='recu_creneau', cr=cr)
 830
 831@app.route('/annuler-creneau/<mat_id>', methods=['POST'])
 832@login_required
 833def annuler_creneau(mat_id):
 834    email = session['user']
 835    if mat_id in CRENEAUX and email in CRENEAUX[mat_id]:
 836        del CRENEAUX[mat_id][email]
 837        add_log('❌ Créneau annulé', f"{session.get('prenom','')} → {MATERIEL.get(mat_id,{}).get('nom','')}", email)
 838        flash("Réservation annulée.", "success")
 839    return redirect(request.referrer or url_for('index'))
 840
 841# ─── Réservation file d'attente ─────────────────────────────────────────────
 842@app.route('/reserver/<mat_id>', methods=['POST'])
 843@login_required
 844def reserver(mat_id):
 845    email = session['user']
 846    mat = MATERIEL.get(mat_id)
 847    if not mat:
 848        flash("Matériel introuvable.", "danger"); return redirect(url_for('index'))
 849    if mat.get('dispo'):
 850        flash("Ce matériel est disponible — empruntez-le directement ou réservez un créneau.", "danger"); return redirect(url_for('index'))
 851    if email in RESERVATIONS.get(mat_id,[]):
 852        flash("Déjà réservé.", "danger"); return redirect(url_for('index'))
 853    if mat_id not in RESERVATIONS: RESERVATIONS[mat_id] = []
 854    RESERVATIONS[mat_id].append(email)
 855    pos = len(RESERVATIONS[mat_id])
 856    add_log('🔔 Réservation', f"{session.get('prenom','')} → {mat['nom']} (n°{pos})", email)
 857    flash(f"Réservé ! Vous êtes n°{pos} dans la file.", "success")
 858    return redirect(url_for('index'))
 859
 860@app.route('/annuler-reservation/<mat_id>', methods=['POST'])
 861@login_required
 862def annuler_reservation(mat_id):
 863    email = session['user']
 864    if mat_id in RESERVATIONS and email in RESERVATIONS[mat_id]:
 865        RESERVATIONS[mat_id].remove(email)
 866        add_log('❌ Résa annulée', f"{session.get('prenom','')} → {MATERIEL.get(mat_id,{}).get('nom','')}", email)
 867        flash("Réservation annulée.", "success")
 868    return redirect(request.referrer or url_for('index'))
 869
 870# ─── Historique ──────────────────────────────────────────────
 871@app.route('/historique')
 872@login_required
 873def historique():
 874    email = session['user']
 875    update_retards_and_notify()
 876    mes   = [e for e in EMPRUNTS.values() if e['user_email']==email]
 877    for e in mes:
 878        e['amende'] = calc_amende(e)
 879        e['heures_restantes'] = heures_restantes(e)
 880    # Sort: active first, then by date_debut descending
 881    mes.sort(key=lambda e: (e['rendu'], e.get('date_debut',''), ), reverse=False)
 882    mes_creneaux_hist = {mat_id: info[email] for mat_id, info in CRENEAUX.items() if email in info}
 883    return render_template('index.html', view='historique', emprunts=mes, mes_creneaux_hist=mes_creneaux_hist, materiel=MATERIEL)
 884
 885# ─── Admin / Resp ─────────────────────────────────────────────
 886@app.route('/admin/materiel')
 887@login_required
 888@role_required('admin','responsable')
 889def admin_materiel_page():
 890    resa_info = {mat_id: [{'email':em,'nom':UTILISATEURS.get(em,{}).get('prenom','?')+' '+UTILISATEURS.get(em,{}).get('nom',''),'pos':i+1} for i,em in enumerate(q)] for mat_id,q in RESERVATIONS.items() if q}
 891    actifs_mat = [e for e in EMPRUNTS.values() if not e['rendu']]
 892    return render_template('index.html', view='resp_materiel', materiel=MATERIEL, resa_info=resa_info, creneaux=CRENEAUX, actifs=actifs_mat)
 893
 894@app.route('/admin/utilisateurs')
 895@login_required
 896@role_required('admin','responsable')
 897def admin_users_page():
 898    users_with_stats = []
 899    for u in UTILISATEURS.values():
 900        emps   = [e for e in EMPRUNTS.values() if e['user_email']==u['email']]
 901        actifs = [e for e in emps if not e['rendu']]
 902        u_copy = dict(u)
 903        u_copy['nb_emprunts'] = len(emps)
 904        u_copy['nb_actifs']   = len(actifs)
 905        u_copy['amende_tot']  = sum(calc_amende(e) for e in actifs)
 906        users_with_stats.append(u_copy)
 907    return render_template('index.html', view='admin_users', users=users_with_stats)
 908
 909@app.route('/admin/logs')
 910@login_required
 911@role_required('admin','responsable')
 912def admin_logs_page():
 913    return render_template('index.html', view='admin_logs', logs=LOGS)
 914
 915@app.route('/admin/blacklist/<path:email>', methods=['POST'])
 916@login_required
 917@role_required('admin')
 918def toggle_blacklist(email):
 919    user = UTILISATEURS.get(email)
 920    if user:
 921        user['blacklist'] = not user.get('blacklist',False)
 922        add_log(f"{'🚫' if user['blacklist'] else '✅'} Compte {'suspendu' if user['blacklist'] else 'réactivé'}", email, session.get('user'))
 923        flash(f"Compte {'suspendu' if user['blacklist'] else 'réactivé'}.", "success")
 924    return redirect(request.referrer or url_for('index'))
 925
 926@app.route('/admin/role/<path:email>', methods=['POST'])
 927@login_required
 928@role_required('admin')
 929def changer_role(email):
 930    user = UTILISATEURS.get(email)
 931    new_role = request.form.get('role','eleve')
 932    if user and email != session['user']:
 933        user['role'] = new_role
 934        add_log('🔄 Rôle', f"{email} → {new_role}", session.get('user'))
 935        flash("Rôle mis à jour.", "success")
 936    return redirect(request.referrer or url_for('admin_users_page'))
 937
 938@app.route('/resp/materiel/ajouter', methods=['POST'])
 939@login_required
 940@role_required('responsable','admin')
 941def ajouter_materiel():
 942    nom=request.form.get('nom','').strip(); etat=request.form.get('etat','Bon').strip()
 943    casier=request.form.get('casier','').strip(); desc=request.form.get('description','').strip()
 944    cat=request.form.get('categorie','Scolaire').strip()
 945    duree=int(request.form.get('duree_max','7') or 7)
 946    duree=min(duree, 31)  # Hard cap at 31 days
 947    casier_statut=request.form.get('casier_statut','libre').strip()
 948    # Auto-detect emoji from name
 949    emoji = auto_emoji(nom)
 950    if not nom or not casier:
 951        flash("Nom et casier obligatoires.", "danger"); return redirect(url_for('admin_materiel_page'))
 952    mat_id = 'M'+str(len(MATERIEL)+1)
 953    MATERIEL[mat_id]={'id':mat_id,'nom':nom,'emoji':emoji,'etat':etat,'dispo':True,'casier':casier,
 954                      'casier_statut':casier_statut,'description':desc,'categorie':cat,'duree_max':duree}
 955    add_log('➕ Matériel', nom, session.get('user'))
 956    flash(f"'{nom}' ajouté avec l'emoji {emoji}.", "success")
 957    return redirect(url_for('admin_materiel_page'))
 958
 959@app.route('/resp/materiel/supprimer/<mat_id>', methods=['POST'])
 960@login_required
 961@role_required('responsable','admin')
 962def supprimer_materiel(mat_id):
 963    mat = MATERIEL.get(mat_id)
 964    if mat:
 965        stock_actuel = mat.get('stock', 1 if mat.get('dispo') else 0)
 966        qtot = mat.get('quantite', 1)
 967        if stock_actuel < qtot:
 968            flash("Impossible : au moins un exemplaire est en cours d'emprunt.", "danger"); return redirect(url_for('admin_materiel_page'))
 969        add_log('🗑️ Matériel supprimé', mat['nom'], session.get('user'))
 970        del MATERIEL[mat_id]
 971        flash("Supprimé.", "success")
 972    return redirect(url_for('admin_materiel_page'))
 973
 974@app.route('/resp/user/ajouter', methods=['POST'])
 975@login_required
 976@role_required('responsable','admin')
 977def ajouter_user():
 978    email=request.form.get('email','').strip().lower(); mdp=request.form.get('mdp','').strip()
 979    nom=request.form.get('nom','').strip(); prenom=request.form.get('prenom','').strip()
 980    role=request.form.get('role','eleve').strip()
 981    if not all([email,mdp,nom,prenom]):
 982        flash("Tous les champs sont obligatoires.", "danger"); return redirect(url_for('admin_users_page'))
 983    if email in UTILISATEURS:
 984        flash("Email déjà utilisé.", "danger"); return redirect(url_for('admin_users_page'))
 985    UTILISATEURS[email]={'email':email,'password':hash_mdp(mdp),'nom':nom,'prenom':prenom,'role':role,'blacklist':False,'mdp_clair':mdp}
 986    add_log('👤 Création', f"{prenom} {nom} ({role})", session.get('user'))
 987    flash(f"{prenom} {nom} créé.", "success")
 988    return redirect(url_for('admin_users_page'))
 989
 990@app.route('/resp/user/supprimer/<path:email>', methods=['POST'])
 991@login_required
 992@role_required('responsable','admin')
 993def supprimer_user(email):
 994    if email == session['user']:
 995        flash("Impossible.", "danger"); return redirect(url_for('admin_users_page'))
 996    if email in UTILISATEURS:
 997        u = UTILISATEURS[email]
 998        add_log('🗑️ Utilisateur supprimé', f"{u['prenom']} {u['nom']}", session.get('user'))
 999        del UTILISATEURS[email]
1000        flash("Supprimé.", "success")
1001    return redirect(url_for('admin_users_page'))
1002
1003@app.route('/changer-mdp', methods=['POST'])
1004@login_required
1005def changer_mdp():
1006    email = session['user']
1007    new_mdp = request.form.get('new_mdp','')
1008    confirm = request.form.get('confirm_mdp','')
1009    user = UTILISATEURS.get(email)
1010    if not user:
1011        flash("Erreur.", "danger"); return redirect(url_for('profil'))
1012    if len(new_mdp) < 6:
1013        flash("Nouveau mot de passe : 6 caractères minimum.", "danger"); return redirect(url_for('profil'))
1014    if new_mdp != confirm:
1015        flash("Les mots de passe ne correspondent pas.", "danger"); return redirect(url_for('profil'))
1016    UTILISATEURS[email]['password'] = hash_mdp(new_mdp)
1017    UTILISATEURS[email]['mdp_clair'] = new_mdp  # Store plaintext for display
1018    add_log('🔑 Changement mdp', email, email)
1019    flash("Mot de passe mis à jour avec succès !", "success")
1020    return redirect(url_for('profil'))
1021
1022
1023# ─── Page de paiement dédiée ────────────────────────────────
1024@app.route('/payer/<emp_id>')
1025@login_required
1026def page_paiement(emp_id):
1027    emp = EMPRUNTS.get(emp_id)
1028    if not emp or emp['user_email'] != session['user']:
1029        flash("Emprunt introuvable.", "danger"); return redirect(url_for('historique'))
1030    if emp.get('amende_payee'):
1031        flash("Cette amende a déjà été réglée.", "info"); return redirect(url_for('historique'))
1032    emp['amende'] = calc_amende(emp)
1033    user = UTILISATEURS.get(session['user'], {})
1034    return render_template('index.html', view='paiement', emp=emp, user=user)
1035
1036@app.route('/payer-amende-card/<emp_id>', methods=['POST'])
1037@login_required
1038def payer_amende_card(emp_id):
1039    emp = EMPRUNTS.get(emp_id)
1040    if not emp or emp['user_email'] != session['user']:
1041        flash("Emprunt introuvable.", "danger"); return redirect(url_for('historique'))
1042    card_num = request.form.get('card_num','').replace(' ','')
1043    card_exp = request.form.get('card_exp','')
1044    card_cvv = request.form.get('card_cvv','')
1045    # Basic validation (fake - test mode)
1046    if len(card_num) < 12 or not card_exp or not card_cvv:
1047        flash("Informations de carte invalides.", "danger"); return redirect(url_for('historique'))
1048    last4 = card_num[-4:]
1049    montant = emp.get('amende', 0)
1050    emp['amende_payee'] = True
1051    user = UTILISATEURS.get(session['user'], {})
1052    user_name = f"{user.get('prenom','')} {user.get('nom','')}"
1053    email_paiement(session['user'], user_name, emp['mat_nom'], montant, emp_id, last4)
1054    add_log('💳 Paiement carte', f"{user_name} · {montant}€ · {emp['mat_nom']} · carte ••••{last4}", session['user'])
1055    flash(f"Paiement de {montant}€ confirmé ! Un email de confirmation vous a été envoyé.", "success")
1056    return redirect(url_for('historique'))
1057
1058@app.route('/admin/voir-mdp/<path:email>')
1059@login_required
1060@role_required('admin')
1061def admin_voir_mdp(email):
1062    # Admin can see hashed password (for debug) — in real app this would be handled differently
1063    user = UTILISATEURS.get(email)
1064    if not user:
1065        flash("Utilisateur introuvable.", "danger"); return redirect(url_for('admin_users_page'))
1066    return render_template('index.html', view='profil_eleve', user=user,
1067                           emprunts=[e for e in EMPRUNTS.values() if e['user_email']==email],
1068                           show_mdp=True)
1069
1070@app.route('/admin/reset-mdp/<path:email>', methods=['POST'])
1071@login_required
1072@role_required('admin')
1073def admin_reset_mdp(email):
1074    user = UTILISATEURS.get(email)
1075    if not user:
1076        flash("Introuvable.", "danger"); return redirect(url_for('admin_users_page'))
1077    new_mdp = ''.join(random.choices(string.ascii_letters+string.digits, k=10))
1078    UTILISATEURS[email]['password'] = hash_mdp(new_mdp)
1079    UTILISATEURS[email]['mdp_clair'] = new_mdp  # Store temporarily for admin to see
1080    add_log('🔑 Reset mdp admin', email, session.get('user'))
1081    flash(f"Nouveau mot de passe de {user.get('prenom','')}: {new_mdp}", "success")
1082    return redirect(request.referrer or url_for('admin_users_page'))
1083
1084@app.route('/messages')
1085@login_required
1086def messages_page():
1087    email = session['user']
1088    role  = session.get('role')
1089    mes = [m for m in MESSAGES if m['de'] == email or m['a'] == email]
1090    if role in ('responsable', 'admin'):
1091        if role == 'admin':
1092            eleves = [u for u in UTILISATEURS.values() if u['role'] in ('eleve', 'responsable')]
1093        else:
1094            eleves = [u for u in UTILISATEURS.values() if u['role'] == 'eleve']
1095    else:
1096        eleves = []
1097    unread = sum(1 for m in mes if not m['lu'] and m['a'] == email)
1098    return render_template('index.html', view='messages', messages=mes,
1099                           eleves=eleves, unread=unread,
1100                           utilisateurs=UTILISATEURS)
1101
1102@app.route('/messages/envoyer', methods=['POST'])
1103@login_required
1104@role_required('responsable', 'admin')
1105def envoyer_message():
1106    dest  = request.form.get('destinataire','').strip()
1107    sujet = request.form.get('sujet','').strip()
1108    contenu = request.form.get('contenu','').strip()
1109    if not dest or not sujet or not contenu:
1110        flash("Tous les champs sont obligatoires.", "danger")
1111        return redirect(url_for('messages_page'))
1112    if dest not in UTILISATEURS:
1113        flash("Destinataire introuvable.", "danger")
1114        return redirect(url_for('messages_page'))
1115    # Responsable can message eleves OR admin. Admin can message anyone.
1116    dest_role = UTILISATEURS[dest]['role']
1117    sender_role = session.get('role')
1118    if sender_role == 'responsable' and dest_role not in ('eleve', 'admin'):
1119        flash("Vous ne pouvez écrire qu'aux élèves ou à l'administration.", "danger")
1120        return redirect(url_for('messages_page'))
1121    if sender_role == 'admin' and dest_role == 'admin':
1122        flash("Vous ne pouvez pas vous envoyer un message à vous-même.", "danger")
1123        return redirect(url_for('messages_page'))
1124    msg_id = f"MSG-{random.randint(10000,99999)}"
1125    from_name = f"{session.get('prenom','')} {session.get('nom','')}"
1126    MESSAGES.insert(0, {
1127        'id': msg_id,
1128        'de': session['user'],
1129        'de_nom': from_name,
1130        'a': dest,
1131        'a_nom': f"{UTILISATEURS[dest].get('prenom','')} {UTILISATEURS[dest].get('nom','')}",
1132        'sujet': sujet,
1133        'contenu': contenu,
1134        'date': now_local().strftime("%d/%m/%Y %H:%M"),
1135        'lu': False,
1136        'replies': []
1137    })
1138    add_log('💬 Message envoyé', f"{from_name} → {dest} · {sujet}", session['user'])
1139    email_nouveau_message(dest, from_name, sujet, contenu)
1140    flash(f"Message envoyé à {UTILISATEURS[dest].get('prenom','')} !", "success")
1141    return redirect(url_for('messages_page'))
1142
1143@app.route('/messages/repondre/<msg_id>', methods=['POST'])
1144@login_required
1145def repondre_message(msg_id):
1146    msg = next((m for m in MESSAGES if m['id'] == msg_id), None)
1147    if not msg:
1148        flash("Message introuvable.", "danger"); return redirect(url_for('messages_page'))
1149    email = session['user']
1150    # Only participants can reply
1151    if email not in (msg['de'], msg['a']):
1152        flash("Accès refusé.", "danger"); return redirect(url_for('messages_page'))
1153    contenu = request.form.get('contenu','').strip()
1154    if not contenu:
1155        flash("Réponse vide.", "danger"); return redirect(url_for('messages_page'))
1156    from_name = f"{session.get('prenom','')} {session.get('nom','')}"
1157    msg['replies'].append({
1158        'de': email,
1159        'de_nom': from_name,
1160        'contenu': contenu,
1161        'date': now_local().strftime("%d/%m/%Y %H:%M")
1162    })
1163    msg['lu'] = True  # Mark as read when replied
1164    # Notify the other party
1165    other = msg['a'] if email == msg['de'] else msg['de']
1166    add_log('💬 Réponse', f"{from_name} → {other}", email)
1167    email_nouveau_message(other, from_name, f"Re: {msg['sujet']}", contenu)
1168    flash("Réponse envoyée !", "success")
1169    return redirect(url_for('messages_page'))
1170
1171@app.route('/messages/lire/<msg_id>', methods=['POST'])
1172@login_required
1173def marquer_lu(msg_id):
1174    msg = next((m for m in MESSAGES if m['id'] == msg_id), None)
1175    if msg and msg['a'] == session['user']:
1176        msg['lu'] = True
1177    return redirect(url_for('messages_page'))
1178
1179@app.route('/messages/supprimer/<msg_id>', methods=['POST'])
1180@login_required
1181@role_required('responsable', 'admin')
1182def supprimer_message(msg_id):
1183    global MESSAGES
1184    MESSAGES = [m for m in MESSAGES if m['id'] != msg_id]
1185    flash("Message supprimé.", "success")
1186    return redirect(url_for('messages_page'))
1187
1188if __name__ == '__main__':
1189    app.run(debug=True)

Template Jinja2 — Interface principale

Un seul fichier HTML gère l'intégralité de l'interface via la variable view passée par Flask. Chaque vue (dashboard, catalogue, emprunts, messagerie, paiement, admin…) est rendue conditionnellement selon le rôle de l'utilisateur connecté.

Jinja2HTML5Firebase JS SDK11 vues1 495 lignes
index.html — 1 495 lignesHTML · Jinja2
   1<!DOCTYPE html>
   2<html lang="fr">
   3<head>
   4    <meta charset="UTF-8">
   5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
   6    <title>LoanTrack — INRACI</title>
   7    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Georgia&display=swap" rel="stylesheet">
   8    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
   9    <script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js">
  10
  11// ── CARD TYPE SELECTOR ──
  12function selectCard(btn, type) {
  13    document.querySelectorAll('.card-type-btn').forEach(function(b){ b.classList.remove('active'); });
  14    btn.classList.add('active');
  15    document.getElementById('card_type_input').value = type;
  16    var net = document.getElementById('card-network');
  17    var labels = {'visa':'VISA','mastercard':'MC','amex':'AMEX','bancontact':'BC'};
  18    var colors = {'visa':'#1a1f71','mastercard':'#eb001b','amex':'#2e77bc','bancontact':'#005499'};
  19    if(net) { net.textContent = labels[type] || type.toUpperCase(); net.style.color = colors[type] || '#fff'; }
  20    var icon = document.getElementById('card-icon');
  21    var icons = {'visa':'💳','mastercard':'🟠','amex':'💚','bancontact':'🇧🇪'};
  22    if(icon) icon.textContent = icons[type] || '💳';
  23}
  24// ── PAIEMENT PAGE ──
  25function fmtCard(el) {
  26    var v = el.value.replace(/\D/g,'').slice(0,16);
  27    el.value = v.replace(/(\d{4})(?=\d)/g,'$1 ');
  28    var prev = document.getElementById('prev-num');
  29    if(prev) prev.textContent = (el.value || '•••• •••• •••• ••••').padEnd(19,'•').slice(0,19);
  30    var icon = document.getElementById('card-icon');
  31    if(icon) {
  32        if(v[0]==='4') icon.textContent='💳';
  33        else if(v[0]==='5') icon.textContent='🟠';
  34        else if(v[0]==='3') icon.textContent='💚';
  35        else icon.textContent='💳';
  36    }
  37}
  38function fmtExp(el) {
  39    var v = el.value.replace(/\D/g,'');
  40    if(v.length >= 2) v = v.slice(0,2) + '/' + v.slice(2,4);
  41    el.value = v;
  42    var prev = document.getElementById('prev-exp');
  43    if(prev) prev.textContent = el.value || 'MM/AA';
  44}
  45function validatePaiement(form) {
  46    var num = form.card_num.value.replace(/\s/g,'');
  47    if(num.length < 13) { alert('Numéro de carte invalide.'); return false; }
  48    var exp = form.card_exp.value;
  49    if(!/^\d{2}\/\d{2}$/.test(exp)) { alert('Date d\'expiration invalide.'); return false; }
  50    var btn = document.getElementById('pay-btn');
  51    var txt = document.getElementById('pay-btn-txt');
  52    if(btn) { btn.disabled = true; btn.style.opacity='0.7'; }
  53    if(txt) txt.textContent = '⏳ Traitement en cours…';
  54    return true;
  55}
  56</script>
  57    <script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-auth-compat.js"></script>
  58
  59</head>
  60<body>
  61
  62<!-- OFFLINE BANNER -->
  63<div class="offline-banner" id="offline-banner">
  64    📡 Connexion perdue — Système hors ligne. Vérifiez votre connexion internet.
  65</div>
  66
  67<!-- HAMBURGER MOBILE -->
  68<button class="hamburger" id="hamburger" onclick="toggleSidebar()" aria-label="Menu">
  69    <span></span><span></span><span></span>
  70</button>
  71<div class="sidebar-overlay" id="sidebar-overlay" onclick="toggleSidebar()"></div>
  72
  73{% with messages = get_flashed_messages(with_categories=true) %}
  74    {% for category, message in messages %}
  75    <div class="flash flash-{{ category }}" onclick="this.remove()">
  76        <span>{{ message }}</span><span class="flash-x">✕</span>
  77    </div>
  78    {% endfor %}
  79{% endwith %}
  80
  81{% if session.get('user') %}
  82{% set role   = session.get('role','eleve') %}
  83{% set prenom = session.get('prenom','U') %}
  84{% set nom    = session.get('nom','') %}
  85
  86<div class="app-wrap">
  87<nav class="sidebar" id="sidebar">
  88    <div class="sidebar-brand">
  89        <div class="brand-logo">LT</div>
  90        <div>
  91            <div class="brand-name">LoanTrack</div>
  92            <div class="brand-sub">INRACI</div>
  93        </div>
  94        {% if role != 'eleve' %}<span class="role-tag {{ role }}">{{ 'Admin' if role=='admin' else 'Resp.' }}</span>{% endif %}
  95    </div>
  96    <a href="{{ url_for('profil') }}" class="sidebar-user {% if view=='profil' %}active-user{% endif %}">
  97        <div class="avatar avatar-{{ role }}">{{ prenom[0]|upper }}{{ nom[0]|upper if nom else '' }}</div>
  98        <div style="overflow:hidden;flex:1">
  99            <div class="user-name">{{ prenom }} {{ nom }}</div>
 100            <div class="user-email">{{ session.get('user') }}</div>
 101        </div>
 102        <span style="color:var(--text3);font-size:0.8rem">›</span>
 103    </a>
 104    <div class="nav-section">
 105        {% if role == 'eleve' %}
 106            <span class="nav-label">Navigation</span>
 107            <a class="nav-item {% if view=='dashboard' %}active{% endif %}" href="{{ url_for('index') }}"><span class="ni">📦</span> Inventaire</a>
 108            <a class="nav-item {% if view=='historique' %}active{% endif %}" href="{{ url_for('historique') }}"><span class="ni">📋</span> Mes emprunts</a>
 109            <a class="nav-item {% if view=='messages' %}active{% endif %}" href="{{ url_for('messages_page') }}"><span class="ni">💬</span> Messages{% if unread_count and unread_count > 0 %} <span class="nav-badge">{{ unread_count }}</span>{% endif %}</a>
 110        {% elif role == 'responsable' %}
 111            <span class="nav-label">Responsable</span>
 112            <a class="nav-item {% if view=='resp_emprunts' %}active{% endif %}" href="{{ url_for('index') }}"><span class="ni">📝</span> Emprunts</a>
 113            <a class="nav-item {% if view=='resp_materiel' %}active{% endif %}" href="{{ url_for('admin_materiel_page') }}"><span class="ni">📦</span> Matériel</a>
 114            <a class="nav-item {% if view=='admin_users' %}active{% endif %}" href="{{ url_for('admin_users_page') }}"><span class="ni">👥</span> Utilisateurs</a>
 115            <a class="nav-item {% if view=='messages' %}active{% endif %}" href="{{ url_for('messages_page') }}"><span class="ni">💬</span> Messages{% if unread_count and unread_count > 0 %} <span class="nav-badge">{{ unread_count }}</span>{% endif %}</a>
 116            <a class="nav-item {% if view=='admin_logs' %}active{% endif %}" href="{{ url_for('admin_logs_page') }}"><span class="ni">📜</span> Logs</a>
 117        {% elif role == 'admin' %}
 118            <span class="nav-label">Administration</span>
 119            <a class="nav-item {% if view=='messages' %}active{% endif %}" href="{{ url_for('messages_page') }}"><span class="ni">💬</span> Messages{% if unread_count and unread_count > 0 %} <span class="nav-badge">{{ unread_count }}</span>{% endif %}</a>
 120            <a class="nav-item {% if view=='admin_logs' %}active{% endif %}" href="{{ url_for('admin_logs_page') }}"><span class="ni">📜</span> Logs</a>
 121        {% endif %}
 122    </div>
 123    <div class="sidebar-footer">
 124        <a class="nav-item nav-out" href="{{ url_for('logout') }}"><span class="ni">🚪</span> Déconnexion</a>
 125    </div>
 126</nav>
 127
 128<main class="main">
 129
 130{# ══ PROFIL ══ #}
 131{% if view == 'profil' %}
 132<div class="page-header"><h1 class="page-title">Mon profil</h1><p class="page-sub">Vos informations et réservations actives.</p></div>
 133<div class="profil-card">
 134    <div class="profil-header">
 135        <div class="profil-avatar avatar-{{ role }}">{{ prenom[0]|upper }}{{ nom[0]|upper if nom else '' }}</div>
 136        <div>
 137            <h2>{{ prenom }} {{ nom }}</h2><p>{{ session.get('user') }}</p>
 138            <span class="badge {% if role=='admin' %}badge-red{% elif role=='responsable' %}badge-brown{% else %}badge-green{% endif %}">{{ role|capitalize }}</span>
 139        </div>
 140    </div>
 141    <div>
 142        <div class="profil-row"><span>Email</span><strong>{{ session.get('user') }}</strong></div>
 143        <div class="profil-row"><span>Rôle</span><strong>{{ role|capitalize }}</strong></div>
 144        {% if role == 'eleve' %}
 145        {% set actifs_p = emprunts|selectattr('rendu','equalto',false)|list %}
 146        <div class="profil-row"><span>Emprunts actifs</span><strong>{{ actifs_p|length }}</strong></div>
 147        <div class="profil-row"><span>Total emprunts</span><strong>{{ emprunts|length }}</strong></div>
 148        {% if mes_resa %}<div class="profil-row"><span>Réservations</span><strong>{{ mes_resa|length }}</strong></div>{% endif %}
 149        {% set tot = namespace(v=0) %}{% for e in actifs_p %}{% set tot.v = tot.v + e.amende %}{% endfor %}
 150        {% if tot.v > 0 %}<div class="profil-row"><span>Amende</span><strong style="color:var(--red)">{{ tot.v }}€</strong></div>{% endif %}
 151        {% endif %}
 152    </div>
 153</div>
 154
 155{# Mot de passe + Changement #}
 156<div class="form-card" style="margin-top:20px;max-width:520px">
 157    <h3 style="margin-bottom:16px">🔑 Mot de passe</h3>
 158    <div class="field-group">
 159        <label>Mot de passe actuel</label>
 160        <div class="pwd-toggle-wrap">
 161            <input type="text" id="current_pwd_display"
 162                   data-val="{{ mdp_clair }}"
 163                   value="••••••••"
 164                   readonly
 165                   style="background:var(--surface2);color:var(--text2);letter-spacing:0.2em;font-family:monospace;cursor:default">
 166            <button type="button" class="pwd-eye" id="pwd-eye-btn" onclick="togglePwdDisplay()">👁</button>
 167        </div>
 168        {% if not mdp_clair %}
 169        <p style="font-size:0.72rem;color:var(--text3);margin-top:4px;font-family:'Inter',sans-serif">⚠ Mot de passe non mémorisé — changez-le ci-dessous pour l'activer.</p>
 170        {% endif %}
 171    </div>
 172    <div class="pwd-divider">Changer de mot de passe</div>
 173    <form method="POST" action="{{ url_for('changer_mdp') }}">
 174        <div class="field-group">
 175            <label>Nouveau mot de passe</label>
 176            <div class="pwd-toggle-wrap">
 177                <input type="password" name="new_mdp" id="new_mdp" placeholder="Min. 6 caractères" required minlength="6">
 178                <button type="button" class="pwd-eye" onclick="togglePwd('new_mdp')">👁</button>
 179            </div>
 180        </div>
 181        <div class="field-group">
 182            <label>Confirmer le nouveau mot de passe</label>
 183            <div class="pwd-toggle-wrap">
 184                <input type="password" name="confirm_mdp" id="confirm_mdp" placeholder="••••••••" required minlength="6">
 185                <button type="button" class="pwd-eye" onclick="togglePwd('confirm_mdp')">👁</button>
 186            </div>
 187        </div>
 188        <button type="submit" class="btn-primary" style="width:auto;padding:9px 22px">Mettre à jour</button>
 189    </form>
 190</div>
 191{% if mes_resa or mes_creneaux %}
 192<div class="section-title" style="margin-top:24px">Mes réservations & créneaux</div>
 193{% for mat_id, cr in mes_creneaux.items() %}{% set m = materiel.get(mat_id) %}{% if m %}
 194<div class="emp-card" style="margin-bottom:10px;border-left:3px solid var(--brown)">
 195    <div class="emp-header">
 196        <div>
 197            <div class="emp-title">{{ m.emoji }} {{ m.nom }}</div>
 198            <div class="emp-meta">📅 Créneau · Retrait : <strong>{{ cr.datetime }}</strong> · Casier : <strong style="color:var(--brown)">{{ cr.casier }}</strong></div>
 199        </div>
 200        <form method="POST" action="{{ url_for('annuler_creneau', mat_id=mat_id) }}"><button class="btn-sm btn-sm-danger">Annuler</button></form>
 201    </div>
 202</div>
 203{% endif %}{% endfor %}
 204{% for mat_id, pos in mes_resa.items() %}{% set m = materiel.get(mat_id) %}{% if m %}
 205<div class="emp-card" style="margin-bottom:10px">
 206    <div class="emp-header">
 207        <div><div class="emp-title">{{ m.emoji }} {{ m.nom }}</div><div class="emp-meta">🔔 File d'attente — position n°{{ pos }}</div></div>
 208        <form method="POST" action="{{ url_for('annuler_reservation', mat_id=mat_id) }}"><button class="btn-sm btn-sm-danger">Annuler</button></form>
 209    </div>
 210</div>
 211{% endif %}{% endfor %}
 212{% endif %}
 213
 214{# ══ PROFIL ÉLÈVE (resp/admin) ══ #}
 215{% elif view == 'profil_eleve' %}
 216<div class="page-header">
 217    <a href="javascript:history.back()" class="btn-back">← Retour</a>
 218    <h1 class="page-title">{{ user.prenom }} {{ user.nom }}</h1><p class="page-sub">Profil complet.</p>
 219</div>
 220<div class="profil-card" style="max-width:680px">
 221    <div class="profil-header">
 222        <div class="profil-avatar avatar-{{ user.role }}">{{ user.prenom[0]|upper }}{{ user.nom[0]|upper if user.nom else '' }}</div>
 223        <div style="flex:1">
 224            <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
 225                <h2>{{ user.prenom }} {{ user.nom }}</h2>
 226                {% if user.email != session.get('user') and user.role != 'admin' %}
 227                <form method="POST" action="{{ url_for('supprimer_user', email=user.email) }}" style="margin:0">
 228                    <button class="btn-sm btn-sm-danger" onclick="return confirm('Supprimer ce compte ?')">🗑️ Supprimer</button>
 229                </form>
 230                {% endif %}
 231            </div>
 232            <p>{{ user.email }}</p>
 233            <span class="badge {% if user.role=='admin' %}badge-red{% elif user.role=='responsable' %}badge-brown{% else %}badge-green{% endif %}">{{ user.role|capitalize }}</span>
 234            {% if user.get('blacklist') %}<span class="badge badge-red" style="margin-left:6px">Suspendu</span>{% endif %}
 235        </div>
 236    </div>
 237    <div>
 238        <div class="profil-row"><span>Email</span><strong>{{ user.email }}</strong></div>
 239        {% if role == 'admin' and user.get('mdp_clair') %}
 240        <div class="profil-row"><span>Mot de passe (temp)</span>
 241            <div style="display:flex;align-items:center;gap:8px">
 242                <strong id="mdp-display" style="filter:blur(4px);cursor:pointer" onclick="document.getElementById('mdp-display').style.filter='none'">{{ user.mdp_clair }}</strong>
 243                <span style="font-size:0.72rem;color:var(--text3)">(cliquer pour révéler)</span>
 244            </div>
 245        </div>
 246        {% endif %}
 247        <div class="profil-row"><span>Total emprunts</span><strong>{{ emprunts|length }}</strong></div>
 248        {% set actifs_e = emprunts|selectattr('rendu','equalto',false)|list %}
 249        <div class="profil-row"><span>Actifs</span><strong>{{ actifs_e|length }}</strong></div>
 250        {% set tot2 = namespace(v=0) %}{% for e in actifs_e %}{% set tot2.v = tot2.v + e.amende %}{% endfor %}
 251        {% if tot2.v > 0 %}<div class="profil-row"><span>Amende</span><strong style="color:var(--red)">{{ tot2.v }}€</strong></div>{% endif %}
 252    </div>
 253</div>
 254{% if role in ['admin','responsable'] %}
 255<div style="max-width:680px;margin:12px auto;display:flex;gap:10px;flex-wrap:wrap">
 256    {% if role == 'admin' %}
 257    <form method="POST" action="{{ url_for('toggle_blacklist', email=user.email) }}">
 258        {% if user.get('blacklist') %}<button class="btn-sm btn-sm-success">✓ Réactiver</button>
 259        {% else %}<button class="btn-sm btn-sm-danger">🚫 Suspendre</button>{% endif %}
 260    </form>
 261    <form method="POST" action="{{ url_for('changer_role', email=user.email) }}" style="display:flex;gap:8px;align-items:center">
 262        <select name="role" class="select-mini">
 263            <option value="eleve" {% if user.role=='eleve' %}selected{% endif %}>Élève</option>
 264            <option value="responsable" {% if user.role=='responsable' %}selected{% endif %}>Responsable</option>
 265        </select>
 266        <button class="btn-sm btn-sm-primary">Modifier rôle</button>
 267    </form>
 268    <form method="POST" action="{{ url_for('admin_reset_mdp', email=user.email) }}">
 269        <button class="btn-sm btn-sm-brown" onclick="return confirm('Réinitialiser le mot de passe de {{ user.prenom }} ?')">🔑 Reset MDP</button>
 270    </form>
 271    {% endif %}
 272</div>
 273{% endif %}
 274{% if emprunts %}
 275<div class="section-title" style="max-width:680px;margin:20px auto 12px">Historique</div>
 276{% for e in emprunts %}
 277<div class="emp-card {% if e.statut=='en_retard' and not e.rendu %}retard{% elif e.rendu %}termine{% endif %}" style="max-width:680px;margin:0 auto 10px">
 278    <div class="emp-header">
 279        <div><div class="emp-title">{{ e.get('mat_emoji','📦') }} {{ e.mat_nom }}</div><div class="emp-meta">{{ e.date_debut }} → {{ e.date_retour }}</div></div>
 280        {% if e.rendu %}<span class="badge badge-grey">Terminé</span>{% elif e.statut=='en_retard' %}<span class="badge badge-red">En retard</span>{% else %}<span class="badge badge-green">En cours</span>{% endif %}
 281    </div>
 282    {% if e.amende > 0 %}<div class="amende-row {% if e.rendu %}paid{% endif %}"><span>Amende{% if e.get('amende_payee') %} (payée){% endif %}</span><strong style="color:{% if e.rendu or e.get('amende_payee') %}var(--accent){% else %}var(--red){% endif %}">{{ e.amende }}€</strong></div>{% endif %}
 283    {% if e.note_retour %}<div class="info-banner" style="margin-top:8px">📝 {{ e.note_retour }}</div>{% endif %}
 284</div>
 285{% endfor %}
 286{% endif %}
 287
 288{# ══ INVENTAIRE ══ #}
 289{% elif view == 'dashboard' %}
 290<div class="page-header"><h1 class="page-title">Inventaire</h1><p class="page-sub">Consulter et emprunter le matériel disponible.</p></div>
 291
 292{# Emprunt actif #}
 293{% if mon_emprunt_actif %}{% set e = mon_emprunt_actif %}
 294<div class="active-loan-banner {% if e.statut=='en_retard' %}banner-danger{% elif e.statut=='en_attente_verification' %}banner-purple{% else %}banner-ok{% endif %}">
 295    <div class="active-loan-left">
 296        <div class="active-loan-emoji">{{ e.get('mat_emoji','📦') }}</div>
 297        <div>
 298            <div class="active-loan-tag">{% if e.statut=='en_attente_verification' %}Retour en vérification{% elif e.statut=='en_retard' %}Emprunt en retard{% else %}Emprunt en cours{% endif %}</div>
 299            <div class="active-loan-nom">{{ e.mat_nom }}</div>
 300            <div class="active-loan-meta">Code : <strong>{{ e.code_unique }}</strong> · Casier : <strong style="color:var(--brown)">{{ e.casier }}</strong> · {% if e.statut=='en_retard' %}<span style="color:var(--red)">{{ e.amende }}€ d'amende</span>{% elif e.statut=='en_attente_verification' %}<span style="color:var(--purple)">En attente de validation</span>{% else %}Retour le {{ e.date_retour }}{% endif %}</div>
 301            {% if e.statut=='en_retard' and e.amende > 0 and not e.get('amende_payee') %}<a href="{{ url_for('page_paiement', emp_id=e.id) }}" class="btn-pay-now">💳 Payer {{ e.amende }}€</a>{% endif %}
 302        </div>
 303    </div>
 304    <a href="{{ url_for('historique') }}" class="active-loan-btn">Voir →</a>
 305</div>
 306{% endif %}
 307
 308{# Hint cliquable #}
 309{% set nb_actifs = mes_emprunts_actifs|length if mes_emprunts_actifs is defined else 0 %}
 310{% if nb_actifs >= 3 %}
 311<div class="max-emprunts-warn">
 312    ⚠️ Vous avez déjà <strong>{{ nb_actifs }} emprunt{% if nb_actifs > 1 %}s{% endif %} actif{% if nb_actifs > 1 %}s{% endif %}</strong> — maximum 3. Rendez du matériel avant d'emprunter à nouveau.
 313</div>
 314{% elif nb_actifs > 0 %}
 315<div class="click-hint">
 316    <span style="font-size:1.1rem">👆</span>
 317    <span>Cliquez sur une carte · {{ 3 - nb_actifs }} emprunt{% if 3 - nb_actifs > 1 %}s{% endif %} restant{% if 3 - nb_actifs > 1 %}s{% endif %}</span>
 318</div>
 319{% else %}
 320<div class="click-hint">
 321    <span style="font-size:1.1rem">👆</span>
 322    <span>Cliquez sur une carte pour voir les détails et emprunter ou réserver le matériel</span>
 323</div>
 324{% endif %}
 325
 326{# Catalogue 2 catégories #}
 327{% for cat_nom in ['Informatique', 'Scolaire'] %}
 328{% set items_cat = [] %}
 329{% for m_id, m in items.items() %}{% if m.get('categorie','') == cat_nom %}{% set _ = items_cat.append((m_id, m)) %}{% endif %}{% endfor %}
 330{% if items_cat %}
 331<div class="cat-section">
 332    <div class="cat-header">
 333        <span style="font-size:1.3rem">{% if cat_nom == 'Informatique' %}💻{% else %}📚{% endif %}</span>
 334        <span class="cat-title">{{ cat_nom }}</span>
 335        <span class="cat-count">{{ items_cat|length }} article{% if items_cat|length > 1 %}s{% endif %}</span>
 336    </div>
 337    <div class="mat-catalog">
 338    {% for m_id, m in items_cat %}
 339    <div class="mat-card {% if not m.dispo %}mat-card-indispo{% endif %}" onclick="openFiche('{{ m_id }}')">
 340        <div class="mat-card-top">
 341            <span class="mat-card-emoji">{{ m.emoji }}</span>
 342            <div class="mat-card-badges">
 343                {% if m.dispo %}<span class="badge badge-green" style="font-size:0.65rem">Disponible</span>
 344                {% else %}<span class="badge badge-red" style="font-size:0.65rem">Emprunté</span>{% endif %}
 345                {% if m.est_reserve_par_moi %}<span class="badge badge-brown" style="font-size:0.63rem">Réservé n°{{ m.ma_position }}</span>{% endif %}
 346            </div>
 347        </div>
 348        <div class="mat-card-nom">{{ m.nom }}</div>
 349        <div class="mat-card-footer">
 350            <span class="badge badge-grey" style="font-size:0.65rem">{{ m.etat }}</span>
 351            <span style="font-size:0.68rem;color:var(--text3)">max {{ m.get('duree_max','7') }}j</span>
 352        </div>
 353        {# Stock indicator #}
 354        {% set qtot = m.get('quantite', 1) %}
 355        {% set stk  = m.get('stock', 1 if m.dispo else 0) %}
 356        {% if qtot > 1 %}
 357        <div class="stock-bar-wrap">
 358            <div class="stock-bar" style="width:{{ (stk/qtot*100)|int }}%;background:{% if stk == 0 %}var(--red){% elif stk <= qtot//2 %}var(--gold){% else %}var(--green){% endif %}"></div>
 359            <span class="stock-label">{{ stk }}/{{ qtot }} dispo</span>
 360        </div>
 361        {% endif %}
 362        {% if not m.dispo and m.get('date_retour_prevu') %}
 363        <div class="mat-card-retour">📅 Dispo le {{ m.date_retour_prevu }}</div>
 364        {% endif %}
 365        {% if m.get('nb_resa',0) > 0 %}
 366        <div style="font-size:0.68rem;color:var(--text3);margin-top:6px;font-family:'Inter',sans-serif">🔔 {{ m.nb_resa }} en file d'attente</div>
 367        {% endif %}
 368    </div>
 369
 370    {# MODAL FICHE #}
 371    <div id="fiche-{{ m_id }}" class="fiche-overlay" onclick="if(event.target===this)closeFiche('{{ m_id }}')">
 372    <div class="fiche-modal">
 373        <button class="fiche-close" onclick="closeFiche('{{ m_id }}')">✕</button>
 374        <div class="fiche-hero">
 375            <div class="fiche-hero-emoji">{{ m.emoji }}</div>
 376            <div>
 377                <h2 class="fiche-nom">{{ m.nom }}</h2>
 378                <div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
 379                    {% if m.dispo %}<span class="badge badge-green">Disponible</span>
 380                    {% else %}<span class="badge badge-red">Emprunté</span>{% endif %}
 381                    <span class="badge badge-grey">{{ m.etat }}</span>
 382                    <span class="badge badge-brown">{{ m.get('categorie','') }}</span>
 383                </div>
 384            </div>
 385        </div>
 386        {% if m.get('description') %}
 387        <div class="fiche-section">
 388            <div class="fiche-section-title">Description</div>
 389            <p class="fiche-desc">{{ m.description }}</p>
 390        </div>
 391        {% endif %}
 392        <div class="fiche-section">
 393            <div class="fiche-section-title">Informations</div>
 394            <div class="fiche-info-grid">
 395                <div class="fiche-info-item"><span>État</span><strong>{{ m.etat }}</strong></div>
 396                <div class="fiche-info-item"><span>Durée max</span><strong>{{ m.get('duree_max','7') }} jours</strong></div>
 397                {% if not m.dispo and m.get('date_retour_prevu') %}<div class="fiche-info-item" style="border-color:rgba(184,134,11,0.3)"><span>Retour prévu</span><strong style="color:var(--gold)">{{ m.date_retour_prevu }}</strong></div>{% endif %}
 398                {% if m.get('nb_resa',0) > 0 %}<div class="fiche-info-item"><span>File d'attente</span><strong>{{ m.nb_resa }} personne{% if m.nb_resa > 1 %}s{% endif %}</strong></div>{% endif %}
 399            </div>
 400        </div>
 401        <div class="fiche-section fiche-action-section">
 402            {# ── DISPONIBLE ── #}
 403            {% if m.dispo %}
 404            <div class="fiche-guide-tip">
 405                <span style="font-size:1.2rem">✅</span>
 406                <span>Ce matériel est <strong>disponible</strong> — remplissez les dates ci-dessous et confirmez !</span>
 407            </div>
 408            <form method="POST" action="{{ url_for('faire_emprunt', mat_id=m_id) }}" style="margin-top:14px">
 409                <div class="date-row">
 410                    <div class="field-group" style="margin:0"><label>📅 Date de début</label><input type="datetime-local" name="date_debut" id="debut-{{ m_id }}" required min="{{ today }}" onchange="updateMinRetour('{{ m_id }}', {{ m.get('duree_max',7) }})"></div>
 411                    <div class="field-group" style="margin:0"><label>🔙 Date de retour (max {{ m.get('duree_max','7') }}j)</label><input type="datetime-local" name="date_retour" id="retour-{{ m_id }}" required min="{{ today }}"></div>
 412                </div>
 413                <div class="fiche-warning">⚠️ Retard : 1 €/jour automatiquement · Prolongement possible une fois (+1 jour) · Un email de confirmation vous sera envoyé</div>
 414                <button type="submit" class="btn-emprunter">✅ Confirmer la réservation</button>
 415            </form>
 416
 417            {% elif not m.dispo %}
 418            {% if m.get('date_retour_prevu') %}
 419            <div class="fiche-retour-info">🕐 Ce matériel est actuellement emprunté — retour prévu le <strong>{{ m.date_retour_prevu }}</strong></div>
 420            {% else %}
 421            <div class="fiche-retour-info">🔴 Ce matériel n'est pas disponible pour le moment</div>
 422            {% endif %}
 423            <p style="font-size:0.85rem;color:var(--text2);margin-bottom:14px;line-height:1.6;font-family:'Inter',sans-serif">Vous avez deux options : <strong>réserver un créneau</strong> pour une date précise, ou <strong>recevoir un email</strong> dès qu'il est rendu.</p>
 424
 425            {% if not m.mon_creneau %}
 426            <div class="fiche-step-header">
 427                <div class="fiche-step-num">A</div>
 428                <div class="fiche-step-label">Réserver pour une date précise (créneau)</div>
 429            </div>
 430            <form method="POST" action="{{ url_for('reserver_creneau', mat_id=m_id) }}" style="margin-bottom:14px">
 431                <div class="date-row">
 432                    <div class="field-group" style="margin:0"><label>📅 Date de retrait souhaitée</label><input type="datetime-local" name="date_creneau" id="creneau-{{ m_id }}" required min="{{ today }}" onchange="updateMinRetourCreneau('{{ m_id }}', {{ m.get('duree_max',7) }})"></div>
 433                    <div class="field-group" style="margin:0"><label>🔙 Date de retour (max {{ m.get('duree_max','7') }}j)</label><input type="datetime-local" name="date_retour_creneau" id="retour-creneau-{{ m_id }}" required min="{{ today }}"></div>
 434                </div>
 435                <div class="fiche-warning">📌 Le matériel sera réservé pour vous à la date choisie</div>
 436                <button type="submit" class="btn-creneau">📅 Réserver ce créneau</button>
 437            </form>
 438            {% else %}
 439            <div class="creneau-actif">
 440                <div class="creneau-actif-icon">📅</div>
 441                <div style="flex:1">
 442                    <div style="font-weight:700;margin-bottom:4px;color:var(--teal)">✅ Créneau réservé !</div>
 443                    <div style="font-size:0.82rem;color:var(--text2)">Retrait : <strong>{{ m.mon_creneau.datetime }}</strong> · Casier : <strong style="color:var(--teal)">{{ m.mon_creneau.casier }}</strong></div>
 444                </div>
 445                <form method="POST" action="{{ url_for('annuler_creneau', mat_id=m_id) }}"><button class="btn-sm btn-sm-danger">Annuler</button></form>
 446            </div>
 447            {% endif %}
 448
 449            <div class="fiche-divider"><span>ou</span></div>
 450
 451            {% if not m.est_reserve_par_moi %}
 452            <div class="fiche-step-header" style="background:linear-gradient(135deg,var(--orange),#c2410c)">
 453                <div class="fiche-step-num">B</div>
 454                <div class="fiche-step-label">Être prévenu par email dès qu'il est rendu</div>
 455            </div>
 456            <form method="POST" action="{{ url_for('reserver', mat_id=m_id) }}">
 457                <button type="submit" class="btn-reserver">🔔 M'avertir par email dès qu'il est disponible</button>
 458            </form>
 459            <p style="font-size:0.75rem;color:var(--text3);margin-top:8px;font-family:'Inter',sans-serif;text-align:center">📧 Vous recevrez un email automatique dès que l'élève rend le matériel</p>
 460            {% else %}
 461            <div class="fiche-alert fiche-alert-brown">
 462                <div>
 463                    <strong>🔔 Vous êtes en file d'attente — position n°{{ m.ma_position }}</strong>
 464                    <div style="font-size:0.8rem;margin-top:4px">Un email vous sera envoyé automatiquement dès que le matériel est rendu.</div>
 465                </div>
 466                <form method="POST" action="{{ url_for('annuler_reservation', mat_id=m_id) }}"><button class="btn-sm btn-sm-danger">Annuler</button></form>
 467            </div>
 468            {% endif %}
 469
 470            {% endif %}
 471        </div>
 472    </div>
 473    </div>
 474    {% endfor %}
 475    </div>
 476</div>
 477{% endif %}
 478{% endfor %}
 479
 480{# ══ REÇU ══ #}
 481{% elif view == 'recu' %}
 482<div class="recu-wrap">
 483    <div class="recu-card">
 484        <div class="recu-header"><div class="recu-check">✓</div><h2>Emprunt confirmé</h2><p>{{ e.id }}</p></div>
 485        <div class="recu-body">
 486            <div class="recu-row"><span>Élève</span><strong>{{ e.user_name }}</strong></div>
 487            <div class="recu-row"><span>Matériel</span><strong>{{ e.get('mat_emoji','') }} {{ e.mat_nom }}</strong></div>
 488            <div class="recu-row"><span>Début</span><strong>{{ e.date_debut }}</strong></div>
 489            <div class="recu-row"><span>Casier</span><strong style="color:var(--brown)">{{ e.casier }}</strong></div>
 490            <div class="recu-row"><span>À rendre le</span><strong style="color:var(--red)">{{ e.date_retour }}</strong></div>
 491            <div class="recu-code-wrap">
 492                <div class="recu-code-label">Code de retrait</div>
 493                <div class="recu-code">{{ e.code_unique }}</div>
 494                <div style="font-size:0.7rem;color:var(--text3);margin-top:8px;font-family:'Inter',sans-serif">À présenter au responsable</div>
 495            </div>
 496            <div class="recu-notice">📧 Email de confirmation envoyé · Retard : 1 €/jour · Prolongement +1 jour possible une fois</div>
 497        </div>
 498        <div class="recu-footer"><a href="{{ url_for('index') }}" class="btn-block">Retour à l'inventaire</a></div>
 499    </div>
 500</div>
 501
 502{# ══ PAGE PAIEMENT ══ #}
 503{% elif view == 'paiement' %}
 504<div class="paiement-page">
 505    {# Header #}
 506    <div class="paiement-header">
 507        <a href="{{ url_for('historique') }}" class="paiement-back">← Retour</a>
 508        <div class="paiement-logo">🔒 LoanTrack — Paiement sécurisé</div>
 509    </div>
 510
 511    <div class="paiement-body">
 512        {# Récap commande #}
 513        <div class="paiement-recap">
 514            <div class="paiement-recap-title">Récapitulatif</div>
 515            <div class="paiement-recap-row">
 516                <span>Amende de retard</span>
 517                <span class="paiement-recap-mat">{{ emp.mat_nom }}</span>
 518            </div>
 519            <div class="paiement-recap-row" style="color:var(--text2);font-size:0.82rem">
 520                <span>Référence</span><span>{{ emp.id }}</span>
 521            </div>
 522            <div class="paiement-recap-row" style="color:var(--text2);font-size:0.82rem">
 523                <span>Date limite dépassée</span><span>{{ emp.date_retour }}</span>
 524            </div>
 525            <div class="paiement-recap-total">
 526                <span>Total à régler</span>
 527                <span class="paiement-total-amount">{{ emp.amende }}€</span>
 528            </div>
 529            <div class="paiement-secure-badge">🔒 Paiement simulé — aucune vraie transaction</div>
 530        </div>
 531
 532        {# Formulaire carte #}
 533        <div class="paiement-form-wrap">
 534            <div class="paiement-form-title">Informations de paiement</div>
 535
 536            {# ── Choix du type de carte ── #}
 537            <div class="card-type-selector">
 538                <button type="button" class="card-type-btn active" data-type="visa" onclick="selectCard(this,'visa')">
 539                    <span class="card-type-logo">VISA</span>
 540                </button>
 541                <button type="button" class="card-type-btn" data-type="mastercard" onclick="selectCard(this,'mastercard')">
 542                    <span class="card-type-logo" style="color:#eb001b">●</span><span class="card-type-logo" style="color:#f79e1b;margin-left:-6px">●</span> MC
 543                </button>
 544                <button type="button" class="card-type-btn" data-type="amex" onclick="selectCard(this,'amex')">
 545                    <span class="card-type-logo">AMEX</span>
 546                </button>
 547                <button type="button" class="card-type-btn" data-type="bancontact" onclick="selectCard(this,'bancontact')">
 548                    <span class="card-type-logo">🇧🇪 BC</span>
 549                </button>
 550            </div>
 551            <input type="hidden" name="card_type" id="card_type_input" value="visa">
 552
 553            <div class="paiement-card-preview" id="card-preview">
 554                <div class="card-chip">▬▬</div>
 555                <div class="card-number-preview" id="prev-num">•••• •••• •••• ••••</div>
 556                <div class="card-bottom-preview">
 557                    <span id="prev-holder">PRÉNOM NOM</span>
 558                    <span id="prev-exp">MM/AA</span>
 559                </div>
 560                <div class="card-network" id="card-network">VISA</div>
 561            </div>
 562
 563            <form method="POST" action="{{ url_for('payer_amende_card', emp_id=emp.id) }}" onsubmit="return validatePaiement(this)" id="pay-form">
 564                <div class="field-group">
 565                    <label>Titulaire de la carte</label>
 566                    <input type="text" name="card_holder" id="inp-holder" placeholder="JEAN DUPONT"
 567                           oninput="document.getElementById('prev-holder').textContent=this.value.toUpperCase()||'PRÉNOM NOM'"
 568                           required style="text-transform:uppercase">
 569                </div>
 570                <div class="field-group">
 571                    <label>Numéro de carte</label>
 572                    <div style="position:relative">
 573                        <input type="text" name="card_num" id="inp-num" placeholder="1234 5678 9012 3456"
 574                               maxlength="19" oninput="fmtCard(this)" required
 575                               style="padding-right:48px;letter-spacing:0.1em">
 576                        <span style="position:absolute;right:12px;top:50%;transform:translateY(-50%);font-size:1.2rem" id="card-icon">💳</span>
 577                    </div>
 578                </div>
 579                <div style="display:flex;gap:14px">
 580                    <div class="field-group" style="flex:1;margin:0">
 581                        <label>Expiration</label>
 582                        <input type="text" name="card_exp" id="inp-exp" placeholder="MM/AA" maxlength="5"
 583                               oninput="fmtExp(this)" required>
 584                    </div>
 585                    <div class="field-group" style="flex:1;margin:0">
 586                        <label>CVV</label>
 587                        <input type="text" name="card_cvv" placeholder="123" maxlength="4"
 588                               inputmode="numeric" required>
 589                    </div>
 590                </div>
 591                <div class="paiement-conditions">
 592                    <label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;font-size:0.82rem;color:var(--text2)">
 593                        <input type="checkbox" required style="margin-top:2px;flex-shrink:0">
 594                        J'accepte que ce montant de <strong style="color:var(--text1)">{{ emp.amende }}€</strong> soit débité pour régulariser mon retard de {{ emp.mat_nom }}.
 595                    </label>
 596                </div>
 597                <button type="submit" class="paiement-submit" id="pay-btn">
 598                    <span id="pay-btn-txt">💳 Payer {{ emp.amende }}€</span>
 599                </button>
 600            </form>
 601            <div class="paiement-footer-logos">
 602                <span class="logo-badge">VISA</span>
 603                <span class="logo-badge">MC</span>
 604                <span class="logo-badge">AMEX</span>
 605                <span class="logo-badge">🔒 SSL</span>
 606            </div>
 607        </div>
 608    </div>
 609</div>
 610
 611{# ══ REÇU CRÉNEAU ══ #}
 612{% elif view == 'recu_creneau' %}
 613<div class="recu-wrap">
 614    <div class="recu-card">
 615        <div class="recu-header" style="background:var(--brown)">
 616            <div class="recu-check">📅</div>
 617            <h2>Réservation confirmée !</h2>
 618            <p>{{ cr.mat_nom }}</p>
 619        </div>
 620        <div class="recu-body">
 621            <div class="recu-row"><span>Matériel</span><strong>{{ cr.mat_emoji }} {{ cr.mat_nom }}</strong></div>
 622            <div class="recu-row"><span>Casier</span><strong style="color:var(--brown)">{{ cr.casier }}</strong></div>
 623            <div class="recu-row"><span>Date de retrait</span><strong>{{ cr.datetime }}</strong></div>
 624            <div class="recu-row"><span>À rendre le</span><strong style="color:var(--red)">{{ cr.date_retour }}</strong></div>
 625            <div class="recu-code-wrap">
 626                <div class="recu-code-label">Code de réservation</div>
 627                <div class="recu-code" style="color:var(--brown)">{{ cr.code }}</div>
 628                <div style="font-size:0.7rem;color:var(--text3);margin-top:8px;font-family:'Inter',sans-serif">À présenter au responsable lors du retrait</div>
 629            </div>
 630            <div class="recu-notice">📅 Présentez-vous au casier <strong style="color:var(--brown)">{{ cr.casier }}</strong> à l'heure réservée</div>
 631        </div>
 632        <div class="recu-footer"><a href="{{ url_for('index') }}" class="btn-block" style="background:var(--brown)">Retour à l'inventaire</a></div>
 633    </div>
 634</div>
 635
 636{# ══ HISTORIQUE ══ #}
 637{% elif view == 'historique' %}
 638<div class="page-header"><h1 class="page-title">Mes réservations</h1><p class="page-sub">Historique de vos réservations, amendes et créneaux.</p></div>
 639
 640{% if mes_creneaux_hist %}
 641<div class="section-title" style="margin-top:0">📅 Créneaux réservés</div>
 642{% for mat_id, cr in mes_creneaux_hist.items() %}{% set m = materiel.get(mat_id) %}
 643<div class="hist-card" style="border-left-color:var(--brown)">
 644    <div class="hist-inner">
 645        <div class="hist-left">
 646            <div class="hist-emoji">{{ cr.mat_emoji }}</div>
 647            <div class="hist-info">
 648                <div class="hist-nom">{{ cr.mat_nom }}</div>
 649                <div class="hist-ref">Code : <strong>{{ cr.code }}</strong> · Casier : <strong style="color:var(--brown)">{{ cr.casier }}</strong></div>
 650                <div class="hist-dates">Retrait : <strong>{{ cr.datetime }}</strong> · Retour prévu : {{ cr.date_retour }}</div>
 651            </div>
 652        </div>
 653        <div class="hist-right">
 654            <span class="badge badge-brown">📅 Créneau</span>
 655            <form method="POST" action="{{ url_for('annuler_creneau', mat_id=mat_id) }}">
 656                <button class="btn-sm btn-sm-danger">Annuler</button>
 657            </form>
 658        </div>
 659    </div>
 660</div>
 661{% endfor %}
 662{% endif %}
 663
 664{# Amendes en attente de paiement — card rapide #}
 665{% set amendes_actives = emprunts|selectattr('rendu','equalto',true)|selectattr('amende_payee','equalto',false)|list %}
 666{% for e in amendes_actives %}{% if e.amende > 0 %}
 667<div class="payment-card">
 668    <div class="payment-header">
 669        <div>
 670            <div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.08em;color:var(--text2);font-family:'Inter',sans-serif;margin-bottom:4px">Amende à régler</div>
 671            <div style="font-weight:700">{{ e.mat_nom }}</div>
 672        </div>
 673        <div class="payment-amount">{{ e.amende }} €</div>
 674    </div>
 675    <div class="payment-info">Retard constaté · Réglez votre amende en ligne.</div>
 676    <a href="{{ url_for('page_paiement', emp_id=e.id) }}" class="btn-primary" style="display:block;text-align:center;margin-top:14px;text-decoration:none">💳 Payer {{ e.amende }}€</a>
 677</div>
 678{% endif %}{% endfor %}
 679
 680{% if emprunts %}
 681{% for e in emprunts %}
 682<div class="hist-card {% if e.statut=='en_retard' and not e.rendu %}hist-retard{% elif e.rendu %}hist-termine{% elif e.statut=='en_attente_verification' %}hist-verif{% endif %}">
 683    <div class="hist-inner">
 684        <div class="hist-left">
 685            <div class="hist-emoji">{{ e.get('mat_emoji','📦') }}</div>
 686            <div class="hist-info">
 687                <div class="hist-nom">{{ e.mat_nom }}</div>
 688                <div class="hist-ref">{{ e.id }} · Code : <strong>{{ e.code_unique }}</strong> · Casier : <strong style="color:var(--brown)">{{ e.casier }}</strong></div>
 689                <div class="hist-dates">{{ e.date_debut }} → {{ e.date_retour }}{% if e.date_prolonge %} <span class="badge badge-brown" style="font-size:0.62rem">+1j prolongé</span>{% endif %}</div>
 690            </div>
 691        </div>
 692        <div class="hist-right">
 693            {% if e.rendu %}<span class="badge badge-grey">Terminé</span>
 694            {% elif e.statut=='en_attente_verification' %}<span class="badge badge-purple">En vérification</span>
 695            {% elif e.statut=='en_retard' %}<span class="badge badge-red">En retard</span>
 696            {% else %}<span class="badge badge-green">En cours</span>{% endif %}
 697            {% if not e.rendu and e.statut != 'en_attente_verification' %}
 698            {% set h = e.heures_restantes %}{% if h is not none %}
 699            {% if h < 0 %}<div class="hist-amende danger">💸 {{ e.amende }}€ (+1€/j)</div>
 700            {% if not e.get('amende_payee') %}<a href="{{ url_for('page_paiement', emp_id=e.id) }}" class="btn-pay-hist">💳 Payer</a>{% endif %}
 701            {% elif e.get('total_minutes_restantes', 9999) <= 60 %}
 702                <div class="hist-amende warning">⚠ {{ e.get('total_minutes_restantes', 0) }} min restante{% if e.get('total_minutes_restantes',0) > 1 %}s{% endif %}</div>
 703            {% elif h <= 2 %}<div class="hist-amende warning">⚠ {{ h }}h {{ e.get('minutes_restantes',0) }}min restantes</div>
 704            {% elif h <= 24 %}<div class="hist-amende warning">⚠ {{ h }}h restantes</div>
 705            {% else %}<div class="hist-amende ok">{{ h }}h restantes</div>{% endif %}
 706            {% endif %}{% endif %}
 707            {% if e.amende > 0 and e.rendu %}
 708            <div class="hist-amende {% if e.get('amende_payee') %}ok{% else %}danger{% endif %}">{{ e.amende }}€ {% if e.get('amende_payee') %}payée{% else %}à régler{% endif %}</div>
 709            {% endif %}
 710        </div>
 711    </div>
 712    {% if not e.rendu and e.statut != 'en_attente_verification' %}
 713    <div class="hist-actions">
 714        {% if not e.date_prolonge %}
 715            <button class="btn-sm btn-sm-brown" onclick="toggleProlong('{{ e.id }}')">📅 Prolonger +1 jour</button>
 716        {% else %}
 717            <span style="font-size:0.75rem;color:var(--text3);font-family:'Inter',sans-serif">🔒 Prolongement utilisé</span>
 718        {% endif %}
 719        <button class="btn-sm btn-sm-blue" onclick="toggleCasier('{{ e.id }}')">🔑 Ouvrir le casier</button>
 720        <form method="POST" action="{{ url_for('declarer_retour', emp_id=e.id) }}"
 721              onsubmit="return confirm('Confirmer le retour du matériel ? Un responsable va inspecter.')">
 722            <button type="submit" class="btn-sm btn-sm-success">📬 Déclarer le retour</button>
 723        </form>
 724    </div>
 725
 726    {# Panneau code casier — indépendant, s'ouvre sous les boutons #}
 727    <div id="casier-{{ e.id }}" class="hidden retour-code-panel">
 728        <div class="retour-code-inline">
 729            <span style="font-size:1.2rem">🔑</span>
 730            <div style="flex:1">
 731                <div style="font-weight:700;font-size:0.85rem;color:var(--text);margin-bottom:2px">Code casier <strong style="color:var(--accent)">{{ e.casier }}</strong></div>
 732                <div style="font-size:0.72rem;color:var(--text2)">Entrez les 6 chiffres reçus par email pour ouvrir le casier</div>
 733            </div>
 734            <input type="text" id="code-input-{{ e.id }}"
 735                   placeholder="000000" maxlength="6" inputmode="numeric"
 736                   class="retour-code-field"
 737                   oninput="this.value=this.value.replace(/[^0-9]/g,'');onCodeInput('{{ e.id }}',this)">
 738            <button type="button" class="btn-sm btn-sm-success" onclick="confirmerOuverture('{{ e.id }}','{{ e.code_unique }}')">🔓 Ouvrir</button>
 739            <button type="button" class="btn-sm btn-sm-danger" onclick="toggleCasier('{{ e.id }}')">✕</button>
 740        </div>
 741        <div id="casier-ok-{{ e.id }}" class="hidden" style="margin-top:10px;padding:8px 12px;background:#dcfce7;border-radius:var(--radius);font-size:0.82rem;color:#16a34a;font-weight:600">
 742            ✅ Code correct — casier {{ e.casier }} déverrouillé !
 743        </div>
 744        <div id="casier-err-{{ e.id }}" class="hidden" style="margin-top:10px;padding:8px 12px;background:#fee2e2;border-radius:var(--radius);font-size:0.82rem;color:#dc2626;font-weight:600">
 745            ❌ Code incorrect. Vérifiez le code reçu par email.
 746        </div>
 747    </div>
 748    {% elif e.statut == 'en_attente_verification' %}
 749    <div class="hist-verif-banner">⏳ Retour déclaré — en attente d'inspection par le responsable</div>
 750    {% endif %}
 751    {% if not e.rendu and not e.date_prolonge and e.statut != 'en_attente_verification' %}
 752    <div id="prolong-{{ e.id }}" class="hidden hist-prolong-form">
 753        <p style="font-size:0.82rem;color:var(--text2);margin-bottom:10px;font-family:'Inter',sans-serif">Prolongement automatique de <strong>1 jour</strong>. Nouvelle date : <strong>{{ e.date_retour }}</strong> + 24h.{% if e.amende > 0 %} L'amende actuelle ({{ e.amende }}€) sera figée.{% endif %}</p>
 754        <form method="POST" action="{{ url_for('prolonger_emprunt', emp_id=e.id) }}">
 755            <button type="submit" class="btn-sm btn-sm-success">✓ Confirmer le prolongement</button>
 756            <button type="button" class="btn-sm btn-sm-danger" onclick="toggleProlong('{{ e.id }}')">Annuler</button>
 757        </form>
 758    </div>
 759    {% endif %}
 760    {% if e.note_retour %}<div class="info-banner" style="margin:0 20px 12px">📝 Note : {{ e.note_retour }}</div>{% endif %}
 761</div>
 762{% endfor %}
 763{% else %}
 764<div class="empty-box"><div class="empty-icon">📭</div><p>Aucun emprunt pour le moment.</p><a href="{{ url_for('index') }}">Parcourir l'inventaire →</a></div>
 765{% endif %}
 766
 767{# ══ RESP EMPRUNTS ══ #}
 768{% elif view == 'resp_emprunts' %}
 769<div class="page-header">
 770    <h1 class="page-title">Gestion des emprunts</h1>
 771    <p class="page-sub">Emprunts actifs, retours à valider et réservations.</p>
 772</div>
 773
 774{# ── CASIER OUVERT (retour de ouvrir_casier_resp ou direct) ── #}
 775{% if casier_ouvert %}
 776<div style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border:2px solid rgba(22,163,74,0.35);border-radius:var(--radius-xl);padding:22px 26px;margin-bottom:24px;box-shadow:var(--shadow)">
 777    <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
 778        <span style="font-size:2rem">🔓</span>
 779        <div style="flex:1">
 780            <div style="font-weight:800;font-size:1rem;color:var(--green);margin-bottom:2px">Casier déverrouillé — {{ casier_ouvert.casier }}</div>
 781            <div style="font-size:0.82rem;color:var(--text2)">{{ casier_ouvert.user_name }} · {{ casier_ouvert.mat_nom }}</div>
 782        </div>
 783        <div style="text-align:center;background:var(--surface);border:2px dashed var(--green);border-radius:var(--radius-lg);padding:14px 22px">
 784            <div style="font-size:0.6rem;text-transform:uppercase;letter-spacing:0.12em;color:var(--text3);margin-bottom:6px">Code d'ouverture</div>
 785            <div style="font-size:2rem;font-weight:800;letter-spacing:0.35em;color:var(--green);font-family:'Courier New',monospace">{{ casier_ouvert.code_unique }}</div>
 786        </div>
 787    </div>
 788</div>
 789{% endif %}
 790
 791{# ── CASIER OUVERT DIRECT ── #}
 792{% if casier_ouvert_direct %}
 793<div style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border:2px solid rgba(22,163,74,0.35);border-radius:var(--radius-xl);padding:22px 26px;margin-bottom:24px;box-shadow:var(--shadow)">
 794    <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
 795        <span style="font-size:2rem">🔓</span>
 796        <div style="flex:1">
 797            <div style="font-weight:800;font-size:1rem;color:var(--green);margin-bottom:2px">Casier {{ casier_ouvert_direct.casier }}</div>
 798            {% if casier_ouvert_direct.emp %}
 799            <div style="font-size:0.82rem;color:var(--text2)">Emprunté par <strong>{{ casier_ouvert_direct.emp.user_name }}</strong> · {{ casier_ouvert_direct.emp.mat_nom }}</div>
 800            {% elif casier_ouvert_direct.mat %}
 801            <div style="font-size:0.82rem;color:var(--text2)">{{ casier_ouvert_direct.mat.emoji }} {{ casier_ouvert_direct.mat.nom }} — <span style="color:var(--green)">Libre</span></div>
 802            {% endif %}
 803        </div>
 804        {% if casier_ouvert_direct.emp %}
 805        <div style="text-align:center;background:var(--surface);border:2px dashed var(--green);border-radius:var(--radius-lg);padding:14px 22px">
 806            <div style="font-size:0.6rem;text-transform:uppercase;letter-spacing:0.12em;color:var(--text3);margin-bottom:6px">Code d'ouverture</div>
 807            <div style="font-size:2rem;font-weight:800;letter-spacing:0.35em;color:var(--green);font-family:'Courier New',monospace">{{ casier_ouvert_direct.emp.code_unique }}</div>
 808        </div>
 809        {% else %}
 810        <div style="padding:14px 22px;background:var(--surface);border-radius:var(--radius-lg);font-size:0.82rem;color:var(--text3)">Aucun emprunt actif sur ce casier</div>
 811        {% endif %}
 812    </div>
 813</div>
 814{% endif %}
 815
 816{# ── NOTIFICATION RETOURS EN ATTENTE ── #}
 817{% if en_attente_retour %}
 818<div class="notif-retour-banner">
 819    <div class="notif-retour-left">
 820        <span class="notif-dot"></span>
 821        <span>📬 <strong>{{ en_attente_retour|length }} retour{% if en_attente_retour|length > 1 %}s{% endif %} à inspecter</strong> — {{ en_attente_retour|map(attribute='mat_nom')|join(', ') }}</span>
 822    </div>
 823    <button class="btn-sm btn-sm-purple" onclick="toggleSection('section-retours')">Voir les retours ▾</button>
 824</div>
 825<div id="section-retours" class="collapsible-section" style="display:none">
 826{% for e in en_attente_retour %}
 827<div class="retour-card">
 828    <div class="retour-left">
 829        <span style="font-size:1.5rem">{{ e.get('mat_emoji','📦') }}</span>
 830        <div>
 831            <div class="retour-nom">{{ e.mat_nom }}</div>
 832            <div class="retour-meta">{{ e.user_name }} · {{ e.id }}</div>
 833        </div>
 834    </div>
 835    <div class="retour-actions">
 836        <form method="POST" action="{{ url_for('valider_retour', emp_id=e.id) }}" style="display:inline">
 837            <input type="hidden" name="etat_ok" value="oui">
 838            <button type="submit" class="btn-sm btn-sm-success" title="Valider — matériel en bon état">✓ OK</button>
 839        </form>
 840        <button type="button" class="btn-sm btn-sm-danger" onclick="toggleProbleme('{{ e.id }}')" title="Signaler un problème — message auto envoyé à l'élève">⚠ Problème</button>
 841    </div>
 842    <div id="probleme-{{ e.id }}" class="hidden" style="margin-top:10px;padding:12px 16px;background:#fff1f2;border-radius:var(--radius);border:1.5px solid rgba(220,38,38,0.2)">
 843        <p style="font-size:0.8rem;color:var(--red);margin-bottom:10px;font-family:'Inter',sans-serif">⚠ Un message sera envoyé automatiquement à {{ e.user_name }} et l'emprunt sera clôturé.</p>
 844        <form method="POST" action="{{ url_for('signaler_probleme', emp_id=e.id) }}" style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
 845            <div class="field-group" style="flex:1;margin:0;min-width:200px">
 846                <label style="font-size:0.72rem">Note (optionnel)</label>
 847                <input type="text" name="note_probleme" placeholder="Ex: écran fissuré, câble manquant...">
 848            </div>
 849            <button type="submit" class="btn-sm btn-sm-danger" style="flex-shrink:0">📤 Confirmer et envoyer</button>
 850            <button type="button" class="btn-sm" onclick="toggleProbleme('{{ e.id }}')" style="flex-shrink:0">Annuler</button>
 851        </form>
 852    </div>
 853</div>
 854{% endfor %}
 855</div>
 856{% endif %}
 857
 858{# ── FILES D'ATTENTE — cadre cliquable ── #}
 859{% if resa_info %}
 860{% set total_resa = namespace(n=0) %}{% for q in resa_info.values() %}{% set total_resa.n = total_resa.n + q|length %}{% endfor %}
 861<div class="resa-toggle-card" onclick="toggleSection('section-resa')">
 862    <div style="display:flex;align-items:center;gap:12px">
 863        <span style="font-size:1.3rem">🔔</span>
 864        <div>
 865            <div style="font-weight:700;font-size:0.9rem">{{ resa_info|length }} matériel{% if resa_info|length > 1 %}s{% endif %} en file d'attente</div>
 866            <div style="font-size:0.75rem;color:var(--text2)">{{ total_resa.n }} élève{% if total_resa.n > 1 %}s{% endif %} en attente — cliquez pour voir</div>
 867        </div>
 868    </div>
 869    <span class="resa-toggle-arrow" id="arrow-resa">▾</span>
 870</div>
 871<div id="section-resa" class="collapsible-section" style="display:none">
 872<div class="resa-section" style="margin-top:0;border-top-left-radius:0;border-top-right-radius:0">
 873    {% for mat_id, queue in resa_info.items() %}
 874    {% set mat = materiel.get(mat_id) %}{% if mat %}
 875    <div class="resa-mat">
 876        <div class="resa-mat-nom">{{ mat.emoji }} {{ mat.nom }} <span class="badge badge-grey" style="font-size:0.65rem">{{ queue|length }} en attente</span></div>
 877        <div class="resa-queue">{% for p in queue %}<div class="resa-person"><span class="pos">n°{{ p.pos }}</span>{{ p.nom }}</div>{% endfor %}</div>
 878    </div>
 879    {% endif %}{% endfor %}
 880</div>
 881</div>
 882{% endif %}
 883
 884{# ── STATS ── #}
 885<div class="stats-row">
 886    <div class="stat-card stat-brown"><div class="stat-val">{{ actifs|length }}</div><div class="stat-lbl">Actifs</div></div>
 887    <div class="stat-card stat-red"><div class="stat-val">{{ actifs|selectattr('statut','equalto','en_retard')|list|length }}</div><div class="stat-lbl">En retard</div></div>
 888    <div class="stat-card stat-purple"><div class="stat-val">{{ en_attente_retour|length }}</div><div class="stat-lbl">À vérifier</div></div>
 889    <div class="stat-card stat-gold"><div class="stat-val">{{ actifs|map(attribute='amende')|sum }}€</div><div class="stat-lbl">Amendes</div></div>
 890</div>
 891
 892{# ── TABLEAU EMPRUNTS ACTIFS ── #}
 893{% if actifs %}
 894<div class="table-wrap"><table>
 895    <thead><tr><th>Élève</th><th>Matériel</th><th>Casier</th><th>Retour prévu</th><th>Amende</th><th>Statut</th><th>Actions</th></tr></thead>
 896    <tbody>
 897    {% for e in actifs %}<tr {% if e.get('en_attente_retour') %}style="background:var(--purple-dim)"{% endif %}>
 898        <td><a href="{{ url_for('voir_eleve', email=e.user_email) }}" style="color:var(--accent);font-weight:600">{{ e.user_name }}</a><br><small style="color:var(--text2)">{{ e.user_email }}</small></td>
 899        <td><strong>{{ e.get('mat_emoji','') }} {{ e.mat_nom }}</strong></td>
 900        <td><span class="badge badge-grey">{{ e.casier }}</span></td>
 901        <td>{{ e.date_retour }}</td>
 902        <td>{% if e.amende > 0 %}<strong style="color:var(--red)">{{ e.amende }}€{% if e.get('amende_payee') %} ✓{% endif %}</strong>{% else %}—{% endif %}</td>
 903        <td>{% if e.statut=='en_attente_verification' %}<span class="badge badge-purple">À vérifier</span>
 904            {% elif e.statut=='en_retard' %}<span class="badge badge-red">En retard</span>
 905            {% else %}<span class="badge badge-green">En cours</span>{% endif %}</td>
 906        <td style="display:flex;gap:5px;flex-wrap:wrap;align-items:center">
 907            {% if e.get('en_attente_retour') %}
 908            <form method="POST" action="{{ url_for('valider_retour', emp_id=e.id) }}" style="display:inline">
 909                <button type="submit" name="etat_ok" value="oui" class="btn-sm btn-sm-success" title="Valider OK">✓ OK</button>
 910            </form>
 911            <button type="button" class="btn-sm btn-sm-danger" onclick="toggleProbleme('tbl-{{ e.id }}')" title="Problème">⚠</button>
 912            {% else %}<span style="color:var(--text3);font-size:0.72rem">—</span>
 913            {% endif %}
 914        </td>
 915    </tr>
 916    {% if e.get('en_attente_retour') %}
 917    <tr id="probleme-tbl-{{ e.id }}" class="hidden">
 918        <td colspan="8" style="padding:10px 14px;background:#fff1f2">
 919            <form method="POST" action="{{ url_for('signaler_probleme', emp_id=e.id) }}" style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
 920                <span style="font-size:0.8rem;color:var(--red);align-self:center">⚠ Message auto envoyé à {{ e.user_name }} :</span>
 921                <input type="text" name="note_probleme" placeholder="Note optionnelle..." style="flex:1;min-width:180px;padding:6px 10px;border:1.5px solid var(--border);border-radius:var(--radius);font-size:0.82rem;font-family:'Inter',sans-serif">
 922                <button type="submit" class="btn-sm btn-sm-danger">📤 Confirmer</button>
 923                <button type="button" class="btn-sm" onclick="toggleProbleme('tbl-{{ e.id }}')">Annuler</button>
 924            </form>
 925        </td>
 926    </tr>
 927    {% endif %}
 928    {% endfor %}
 929    </tbody>
 930</table></div>
 931{% else %}<div class="empty-box"><div class="empty-icon">✅</div><p>Aucun emprunt actif.</p></div>{% endif %}
 932
 933{# ══ MATÉRIEL ══ #}
 934{% elif view == 'resp_materiel' %}
 935<div class="page-header">
 936    <a href="{{ url_for('index') }}" class="btn-back">← Retour</a>
 937    <h1 class="page-title">Gestion du matériel</h1><p class="page-sub">Inventaire complet — 2 catégories.</p>
 938</div>
 939<div class="form-card">
 940    <h3>➕ Ajouter du matériel</h3>
 941    <form method="POST" action="{{ url_for('ajouter_materiel') }}">
 942        <div class="form-row">
 943            <div class="field-group"><label>Nom</label><input type="text" name="nom" placeholder="Calculatrice TI-84" required></div>
 944            <div class="field-group">
 945                <label>Casier</label>
 946                <select name="casier">
 947                    {% set casiers_occupes = [] %}
 948                    {% for m2 in materiel.values() %}
 949                        {% if not m2.dispo %}{% set _ = casiers_occupes.append(m2.casier) %}{% endif %}
 950                    {% endfor %}
 951                    {% set casiers_existants = materiel.values()|map(attribute='casier')|list|sort|unique|list %}
 952                    {% for c in casiers_existants %}
 953                    <option value="{{ c }}">{{ c }} — {% if c in casiers_occupes %}🔴 Occupé{% else %}🟢 Libre{% endif %}</option>
 954                    {% endfor %}
 955                </select>
 956            </div>
 957            <div class="field-group"><label>Catégorie</label>
 958                <select name="categorie"><option value="Informatique">Informatique</option><option value="Scolaire">Scolaire</option></select>
 959            </div>
 960            <div class="field-group"><label>État</label><select name="etat"><option>Neuf</option><option selected>Bon</option><option>Usé</option></select></div>
 961            <div class="field-group"><label>Durée max (j) <span style="color:var(--text3);font-size:0.75rem">max 31</span></label><input type="number" name="duree_max" value="7" min="1" max="31"></div>
 962        </div>
 963        <div class="field-group"><label>Description</label><input type="text" name="description" placeholder="Description courte..."></div>
 964        <p style="font-size:0.78rem;color:var(--text2);margin-bottom:12px;font-family:'Inter',sans-serif">💡 L'emoji est détecté automatiquement · Le statut du casier est mis à jour automatiquement.</p>
 965        <button type="submit" class="btn-primary" style="width:auto;padding:9px 24px">Ajouter</button>
 966    </form>
 967</div>
 968<h3 style="margin-bottom:14px;font-size:0.95rem;color:var(--text1)">🔑 Ouvrir un casier</h3>
 969<div class="casier-grid">
 970{% set casiers_list = materiel.values()|map(attribute='casier')|list|sort|unique|list %}
 971{% for casier in casiers_list %}
 972{% set mat_casier = materiel.values()|selectattr('casier','equalto',casier)|first %}
 973{% set emp_casier = none %}
 974{% for e in actifs if e.casier == casier and not e.rendu %}{% set emp_casier = e %}{% endfor %}
 975<a href="{{ url_for('ouvrir_casier_direct', casier_id=casier) }}" class="casier-btn {% if mat_casier and not mat_casier.dispo %}casier-btn-occupied{% else %}casier-btn-free{% endif %}" title="{{ mat_casier.nom if mat_casier else casier }}">
 976    <span class="casier-id">{{ casier }}</span>
 977    <span class="casier-emoji">{{ mat_casier.emoji if mat_casier else '📦' }}</span>
 978    <span class="casier-status">{% if mat_casier and not mat_casier.dispo %}🔴{% else %}🟢{% endif %}</span>
 979</a>
 980{% endfor %}
 981</div>
 982
 983<div class="table-wrap" style="margin-top:24px"><table>
 984    <thead><tr><th>Matériel</th><th>Catégorie</th><th>Casier</th><th>État</th><th>Statut</th><th>File d'attente</th><th>Action</th></tr></thead>
 985    <tbody>{% for m_id, m in materiel.items() %}<tr>
 986        <td><strong>{{ m.emoji }} {{ m.nom }}</strong></td>
 987        <td><span class="badge badge-brown">{{ m.get('categorie','—') }}</span></td>
 988        <td><span class="badge badge-grey">{{ m.casier }}</span></td>
 989        <td><span class="badge badge-grey">{{ m.etat }}</span></td>
 990        <td>{% if m.dispo %}<span class="badge badge-green">🟢 Disponible</span>{% else %}<span class="badge badge-red">🔴 Emprunté</span>{% endif %}</td>
 991        <td>{% if m_id in resa_info and resa_info[m_id] %}<span class="badge badge-brown">{{ resa_info[m_id]|length }} pers.</span>{% else %}<span style="color:var(--text3)">—</span>{% endif %}</td>
 992        <td>{% if m.dispo %}<form method="POST" action="{{ url_for('supprimer_materiel', mat_id=m_id) }}"><button class="btn-sm btn-sm-danger">Suppr.</button></form>{% else %}<span style="color:var(--text3)">—</span>{% endif %}</td>
 993    </tr>{% endfor %}</tbody>
 994</table></div>
 995
 996{# ══ UTILISATEURS ══ #}
 997{% elif view == 'admin_users' %}
 998<div class="page-header">
 999    <a href="javascript:history.back()" class="btn-back">← Retour</a>
1000    <h1 class="page-title">Utilisateurs</h1><p class="page-sub">Gestion des comptes.</p>
1001</div>
1002<div class="form-card">
1003    <h3>👤 Créer un compte</h3>
1004    <form method="POST" action="{{ url_for('ajouter_user') }}">
1005        <div class="form-row">
1006            <div class="field-group"><label>Prénom</label><input type="text" name="prenom" required></div>
1007            <div class="field-group"><label>Nom</label><input type="text" name="nom" required></div>
1008            <div class="field-group"><label>Email</label><input type="email" name="email" required></div>
1009            <div class="field-group"><label>Mot de passe</label><input type="password" name="mdp" required minlength="6"></div>
1010            <div class="field-group"><label>Rôle</label><select name="role"><option value="eleve">Élève</option><option value="responsable">Responsable</option></select></div>
1011        </div>
1012        <button type="submit" class="btn-primary" style="width:auto;padding:9px 24px;margin-top:12px">Créer</button>
1013    </form>
1014</div>
1015<div class="table-wrap"><table>
1016    <thead><tr><th>Nom</th><th>Email</th><th>Rôle</th><th>Emprunts</th><th>Amende</th><th>Statut</th><th>Actions</th></tr></thead>
1017    <tbody>{% for u in users %}<tr>
1018        <td><a href="{{ url_for('voir_eleve', email=u.email) }}" style="color:var(--accent);font-weight:600">{{ u.prenom }} {{ u.nom }}</a></td>
1019        <td style="color:var(--text2)">{{ u.email }}</td>
1020        <td>{% if u.role=='admin' %}<span class="badge badge-red">Admin</span>{% elif u.role=='responsable' %}<span class="badge badge-brown">Resp.</span>{% else %}<span class="badge badge-green">Élève</span>{% endif %}</td>
1021        <td>{{ u.get('nb_emprunts',0) }}</td>
1022        <td>{% if u.get('amende_tot',0) > 0 %}<strong style="color:var(--red)">{{ u.amende_tot }}€</strong>{% else %}—{% endif %}</td>
1023        <td>{% if u.get('blacklist') %}<span class="badge badge-red">Suspendu</span>{% else %}<span class="badge badge-green">Actif</span>{% endif %}</td>
1024        <td>{% if u.email != session.get('user') and u.role != 'admin' %}<form method="POST" action="{{ url_for('supprimer_user', email=u.email) }}"><button class="btn-sm btn-sm-danger">Suppr.</button></form>{% else %}—{% endif %}</td>
1025    </tr>{% endfor %}</tbody>
1026</table></div>
1027
1028{# ══ LOGS ══ #}
1029{% elif view == 'admin_logs' %}
1030<div class="page-header">
1031    <a href="javascript:history.back()" class="btn-back">← Retour</a>
1032    <h1 class="page-title">Journal d'activité</h1><p class="page-sub">Toutes les actions enregistrées.</p>
1033</div>
1034<div class="form-card" style="padding:0;overflow:hidden">
1035    {% if logs %}{% for log in logs %}
1036    <div class="log-row"><span class="log-time">{{ log.datetime }}</span><span class="log-action">{{ log.action }}</span><span class="log-detail">{{ log.detail }}</span><span style="margin-left:auto;font-size:0.68rem;color:var(--text3)">{{ log.user }}</span></div>
1037    {% endfor %}{% else %}<div class="empty-box"><div class="empty-icon">📭</div><p>Aucune activité.</p></div>{% endif %}
1038</div>
1039
1040{# ══ ADMIN SYSTEM ══ #}
1041{% elif view == 'admin_system' %}
1042<div class="page-header"><h1 class="page-title">Système</h1><p class="page-sub">Supervision de LoanTrack INRACI.</p></div>
1043<div class="stats-row">
1044    <div class="stat-card stat-green"><div class="stat-val">{{ stats.actifs }}</div><div class="stat-lbl">Actifs</div></div>
1045    <div class="stat-card stat-red"><div class="stat-val">{{ stats.en_retard }}</div><div class="stat-lbl">En retard</div></div>
1046    <div class="stat-card stat-purple"><div class="stat-val">{{ stats.en_attente_retour }}</div><div class="stat-lbl">À vérifier</div></div>
1047    <div class="stat-card stat-gold"><div class="stat-val">{{ stats.amendes_actives }}€</div><div class="stat-lbl">Amendes</div></div>
1048    <div class="stat-card"><div class="stat-val">{{ stats.nb_users }}</div><div class="stat-lbl">Utilisateurs</div></div>
1049    <div class="stat-card stat-green"><div class="stat-val">{{ stats.termines }}</div><div class="stat-lbl">Terminés</div></div>
1050</div>
1051<div class="section-title">Activité récente</div>
1052<div class="form-card" style="padding:0;overflow:hidden">
1053    {% if logs %}{% for log in logs[:15] %}
1054    <div class="log-row"><span class="log-time">{{ log.datetime }}</span><span class="log-action">{{ log.action }}</span><span class="log-detail">{{ log.detail }}</span></div>
1055    {% endfor %}{% else %}<div class="empty-box"><div class="empty-icon">📭</div><p>Aucune activité.</p></div>{% endif %}
1056</div>
1057
1058{# ══ MESSAGES ══ #}
1059{% elif view == 'messages' %}
1060<div class="page-header">
1061    <h1 class="page-title">💬 Messages</h1>
1062    <p class="page-sub">
1063        {% if role == 'admin' %}Messages des responsables et élèves.
1064        {% elif role == 'responsable' %}Communiquez avec les élèves et l'administration.
1065        {% else %}Vos messages du responsable.{% endif %}
1066    </p>
1067</div>
1068
1069{% if role in ['responsable','admin'] %}
1070<div class="msg-compose-tabs">
1071    {% if role == 'responsable' %}
1072    <button class="msg-tab active" id="tab-eleve-btn" onclick="switchMsgTab('eleve')">👨‍🎓 Écrire à un élève</button>
1073    <button class="msg-tab" id="tab-admin-btn" onclick="switchMsgTab('admin')">🔴 Signaler à l'admin</button>
1074    {% endif %}
1075</div>
1076
1077{# Formulaire → élève (ou admin voit juste ce formulaire) #}
1078<div class="form-card" id="compose-eleve" style="margin-bottom:20px">
1079    <h3 style="margin-bottom:16px">✉️ {% if role=='admin' %}Nouveau message{% else %}Message à un élève{% endif %}</h3>
1080    <form method="POST" action="{{ url_for('envoyer_message') }}">
1081        <div class="form-row">
1082            <div class="field-group" style="flex:1">
1083                <label>Destinataire</label>
1084                <select name="destinataire" required>
1085                    <option value="">— Choisir —</option>
1086                    {% if role == 'admin' %}
1087                        {% set responsables = utilisateurs.values()|selectattr('role','equalto','responsable')|list %}
1088                        {% if responsables %}
1089                        <optgroup label="Responsables">
1090                        {% for u in responsables %}
1091                        <option value="{{ u.email }}">{{ u.prenom }} {{ u.nom }}</option>
1092                        {% endfor %}
1093                        </optgroup>
1094                        {% endif %}
1095                        <optgroup label="Élèves">
1096                        {% for u in eleves %}
1097                        <option value="{{ u.email }}">{{ u.prenom }} {{ u.nom }}</option>
1098                        {% endfor %}
1099                        </optgroup>
1100                    {% else %}
1101                        {% for e in eleves %}
1102                        <option value="{{ e.email }}" {% if prefill_dest is defined and prefill_dest == e.email %}selected{% endif %}>{{ e.prenom }} {{ e.nom }}</option>
1103                        {% endfor %}
1104                    {% endif %}
1105                </select>
1106            </div>
1107            <div class="field-group" style="flex:2">
1108                <label>Sujet</label>
1109                <input type="text" name="sujet" placeholder="Ex: Rappel retour matériel" required value="{{ prefill_sujet if prefill_sujet is defined else '' }}">
1110            </div>
1111        </div>
1112        <div class="field-group">
1113            <textarea name="contenu" rows="3" placeholder="Votre message..." required style="width:100%;padding:10px 14px;border:1.5px solid var(--border);border-radius:var(--radius);font-family:'Inter',sans-serif;font-size:0.875rem;resize:vertical;background:var(--surface);color:var(--text);line-height:1.6">{{ prefill_contenu if prefill_contenu is defined else '' }}</textarea>
1114        </div>
1115        <button type="submit" class="btn-primary" style="width:auto;padding:9px 24px">📤 Envoyer</button>
1116    </form>
1117</div>
1118
1119{% if role == 'responsable' %}
1120{# Formulaire → admin (signalement) #}
1121<div class="form-card hidden" id="compose-admin" style="margin-bottom:20px;border-left:3px solid var(--red)">
1122    <h3 style="margin-bottom:6px;color:var(--red)">🔴 Signaler un problème à l'administration</h3>
1123    <p style="font-size:0.78rem;color:var(--text2);margin-bottom:14px;font-family:'Inter',sans-serif">Matériel endommagé, incident, problème technique... L'admin sera notifié immédiatement.</p>
1124    <form method="POST" action="{{ url_for('envoyer_message') }}">
1125        {% for u in utilisateurs.values() if u.role == 'admin' %}
1126        <input type="hidden" name="destinataire" value="{{ u.email }}">
1127        {% endfor %}
1128        <div class="field-group">
1129            <label>Type de signalement</label>
1130            <select name="sujet" required>
1131                <option value="">— Choisir —</option>
1132                <option value="🔴 Matériel endommagé">🔴 Matériel endommagé</option>
1133                <option value="⚠️ Incident élève">⚠️ Incident élève</option>
1134                <option value="🔧 Problème technique">🔧 Problème technique</option>
1135                <option value="📦 Stock à renouveler">📦 Stock à renouveler</option>
1136                <option value="🚨 Urgence">🚨 Urgence</option>
1137                <option value="💬 Autre">💬 Autre</option>
1138            </select>
1139        </div>
1140        <div class="field-group">
1141            <textarea name="contenu" rows="4" placeholder="Décrivez le problème en détail..." required style="width:100%;padding:10px 14px;border:1.5px solid var(--border);border-radius:var(--radius);font-family:'Inter',sans-serif;font-size:0.875rem;resize:vertical;background:var(--surface);color:var(--text);line-height:1.6"></textarea>
1142        </div>
1143        <button type="submit" class="btn-primary" style="width:auto;padding:9px 24px;background:var(--red);border-color:var(--red)">🚨 Envoyer le signalement</button>
1144    </form>
1145</div>
1146{% endif %}
1147{% endif %}
1148
1149{# Liste des messages #}
1150{% if messages %}
1151<div class="section-title" style="margin-top:0">Conversations ({{ messages|length }})</div>
1152{% for msg in messages %}
1153<div class="msg-card {% if not msg.lu and msg.a == session.get('user') %}msg-unread{% endif %}" id="msg-{{ msg.id }}">
1154    <div class="msg-header" onclick="toggleMsg('{{ msg.id }}')">
1155        <div class="msg-avatar">{{ msg.de_nom[0]|upper }}</div>
1156        <div class="msg-meta">
1157            <div class="msg-subject">
1158                {{ msg.sujet }}
1159                {% if not msg.lu and msg.a == session.get('user') %}<span class="badge badge-blue" style="font-size:0.6rem;margin-left:6px">Nouveau</span>{% endif %}
1160                {% if msg.replies %}<span class="badge badge-grey" style="font-size:0.6rem;margin-left:4px">{{ msg.replies|length }} réponse{% if msg.replies|length > 1 %}s{% endif %}</span>{% endif %}
1161            </div>
1162            <div class="msg-from">
1163                De : <strong>{{ msg.de_nom }}</strong> → <strong>{{ msg.a_nom }}</strong>
1164                <span class="msg-date">· {{ msg.date }}</span>
1165            </div>
1166        </div>
1167        <span class="msg-chevron">›</span>
1168    </div>
1169    <div class="msg-body hidden" id="msgbody-{{ msg.id }}">
1170        {# Message principal #}
1171        <div class="msg-content">{{ msg.contenu }}</div>
1172
1173        {# Réponses #}
1174        {% if msg.replies %}
1175        <div class="msg-replies">
1176            {% for r in msg.replies %}
1177            <div class="msg-reply {% if r.de == session.get('user') %}msg-reply-mine{% endif %}">
1178                <div class="msg-reply-author">{{ r.de_nom }} · {{ r.date }}</div>
1179                <div class="msg-reply-text">{{ r.contenu }}</div>
1180            </div>
1181            {% endfor %}
1182        </div>
1183        {% endif %}
1184
1185        {# Formulaire réponse #}
1186        <div class="msg-reply-form">
1187            <form method="POST" action="{{ url_for('repondre_message', msg_id=msg.id) }}" style="display:flex;gap:10px;align-items:flex-end">
1188                <div style="flex:1">
1189                    <textarea name="contenu" rows="2" placeholder="Écrire une réponse..." required
1190                        style="width:100%;padding:9px 13px;border:1.5px solid var(--border);border-radius:var(--radius);font-family:'Inter',sans-serif;font-size:0.85rem;resize:none;background:var(--surface);color:var(--text)"></textarea>
1191                </div>
1192                <button type="submit" class="btn-sm btn-sm-primary" style="padding:8px 16px;white-space:nowrap">↩ Répondre</button>
1193            </form>
1194        </div>
1195
1196        {# Marquer lu + Supprimer #}
1197        <div style="display:flex;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--border)">
1198            {% if not msg.lu and msg.a == session.get('user') %}
1199            <form method="POST" action="{{ url_for('marquer_lu', msg_id=msg.id) }}">
1200                <button class="btn-sm btn-sm-success" style="font-size:0.72rem">✓ Marquer lu</button>
1201            </form>
1202            {% endif %}
1203            {% if role in ['responsable','admin'] %}
1204            <form method="POST" action="{{ url_for('supprimer_message', msg_id=msg.id) }}" onsubmit="return confirm('Supprimer ce message ?')">
1205                <button class="btn-sm btn-sm-danger" style="font-size:0.72rem">🗑️ Supprimer</button>
1206            </form>
1207            {% endif %}
1208        </div>
1209    </div>
1210</div>
1211{% endfor %}
1212{% else %}
1213<div class="empty-box"><div class="empty-icon">💬</div><p>Aucun message pour le moment.</p></div>
1214{% endif %}
1215
1216{% endif %}
1217</main>
1218</div>
1219
1220{# ══ AUTH ══ #}
1221{% else %}
1222<a href="https://enes-atmac.be" target="_blank" class="auth-site-link">🌐 enes-atmac.be ↗</a>
1223<div class="auth-center">
1224    <div class="auth-panel">
1225        <div class="auth-brand">
1226            <div class="auth-brand-logo">LT</div>
1227            <div class="auth-brand-name">LoanTrack</div>
1228            <div class="auth-brand-sub">INRACI · Gestion d'emprunts</div>
1229        </div>
1230
1231        <div id="blk-login" {% if view in ['register','forgot'] %}class="hidden"{% endif %}>
1232            <h2 class="auth-title">Bon retour</h2>
1233            <p class="auth-sub">Connectez-vous à votre compte</p>
1234            <button class="btn-google" onclick="googleLogin()">
1235                <svg width="18" height="18" viewBox="0 0 48 48"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.18 1.48-4.97 2.36-8.16 2.36-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>
1236                Continuer avec Google
1237            </button>
1238            <div class="divider"><span>ou</span></div>
1239            <form method="POST" action="{{ url_for('login') }}">
1240                <div class="field-group"><label>Email</label><input type="email" name="email" placeholder="prenom.nom@email.com" required></div>
1241                <div class="field-group"><label>Mot de passe</label><input type="password" name="mdp" placeholder="••••••••" required></div>
1242                <div style="text-align:right;margin-bottom:14px"><span onclick="showForgot()" style="font-size:0.78rem;color:var(--text2);cursor:pointer;font-family:'Inter',sans-serif">Mot de passe oublié ?</span></div>
1243                <button type="submit" class="btn-primary">Se connecter</button>
1244
1245            </form>
1246            <p class="auth-switch">Pas de compte ? <span onclick="showReg()">S'inscrire</span></p>
1247        </div>
1248
1249        <div id="blk-register" {% if view != 'register' %}class="hidden"{% endif %}>
1250            <h2 class="auth-title">Créer un compte</h2>
1251            <p class="auth-sub">Rejoignez LoanTrack INRACI</p>
1252            <form method="POST" action="{{ url_for('register') }}" id="form-register">
1253                <div class="field-row">
1254                    <div class="field-group"><label>Prénom</label><input type="text" name="prenom" placeholder="Jean" required></div>
1255                    <div class="field-group"><label>Nom</label><input type="text" name="nom" placeholder="Dupont" required></div>
1256                </div>
1257                <div class="field-group"><label>Email</label><input type="email" name="email" placeholder="jean@email.com" required></div>
1258                <div class="field-group"><label>Mot de passe</label><input type="password" name="mdp" id="mdp" placeholder="••••••••" required minlength="6"></div>
1259                <div class="field-group"><label>Confirmer</label><input type="password" name="mdp_confirm" id="mdp_confirm" placeholder="••••••••" required minlength="6"></div>
1260                <p id="mdp-error" style="color:var(--red);font-size:0.78rem;margin-bottom:8px;display:none;font-family:'Inter',sans-serif">⚠ Les mots de passe ne correspondent pas.</p>
1261                <button type="submit" class="btn-primary">Créer mon compte</button>
1262            </form>
1263            <p class="auth-switch">Déjà inscrit ? <span onclick="showLogin()">Se connecter</span></p>
1264        </div>
1265
1266        <div id="blk-forgot" {% if view != 'forgot' %}class="hidden"{% endif %}>
1267            <h2 class="auth-title">Mot de passe oublié</h2>
1268            <p class="auth-sub">Un nouveau mot de passe vous sera envoyé par email.</p>
1269            <form method="POST" action="{{ url_for('forgot_password') }}">
1270                <div class="field-group"><label>Email</label><input type="email" name="email" placeholder="prenom.nom@email.com" required></div>
1271                <button type="submit" class="btn-primary">Envoyer</button>
1272            </form>
1273            <p class="auth-switch"><span onclick="showLogin()">← Retour à la connexion</span></p>
1274        </div>
1275    </div>
1276</div>
1277<form id="google-form" method="POST" action="{{ url_for('google_login') }}" class="hidden">
1278    <input type="hidden" name="id_token" id="google-token">
1279</form>
1280{% endif %}
1281
1282<script>
1283var firebaseConfig={apiKey:"AIzaSyClDvejbupe0gsRNutCMgTsUi8ZhRK2cO8",authDomain:"loantrack-d9a48.firebaseapp.com",projectId:"loantrack-d9a48",storageBucket:"loantrack-d9a48.firebasestorage.app",messagingSenderId:"751159254603",appId:"1:751159254603:web:d40227ce96e39e1d06f7ce"};
1284if(typeof firebase!=='undefined'&&!firebase.apps.length)firebase.initializeApp(firebaseConfig);
1285function googleLogin(){var p=new firebase.auth.GoogleAuthProvider();p.setCustomParameters({prompt:'select_account'});firebase.auth().signInWithPopup(p).then(function(r){return r.user.getIdToken(true);}).then(function(t){document.getElementById('google-token').value=t;document.getElementById('google-form').submit();}).catch(function(e){alert('Erreur : '+e.message);});}
1286function showLogin(){['blk-register','blk-forgot'].forEach(function(id){var el=document.getElementById(id);if(el)el.classList.add('hidden');});document.getElementById('blk-login').classList.remove('hidden');}
1287function showReg(){['blk-login','blk-forgot'].forEach(function(id){var el=document.getElementById(id);if(el)el.classList.add('hidden');});document.getElementById('blk-register').classList.remove('hidden');}
1288function showForgot(){['blk-login','blk-register'].forEach(function(id){var el=document.getElementById(id);if(el)el.classList.add('hidden');});document.getElementById('blk-forgot').classList.remove('hidden');}
1289var mdpI=document.getElementById('mdp'),mdpC=document.getElementById('mdp_confirm'),err=document.getElementById('mdp-error');
1290if(mdpC)mdpC.addEventListener('input',function(){if(err)err.style.display=(mdpI.value&&mdpC.value&&mdpI.value!==mdpC.value)?'block':'none';});
1291var fReg=document.getElementById('form-register');if(fReg)fReg.addEventListener('submit',function(ev){if(mdpI&&mdpC&&mdpI.value!==mdpC.value){ev.preventDefault();if(err)err.style.display='block';}});
1292function openFiche(id){document.getElementById('fiche-'+id).classList.add('fiche-open');document.body.style.overflow='hidden';}
1293function closeFiche(id){document.getElementById('fiche-'+id).classList.remove('fiche-open');document.body.style.overflow='';}
1294document.addEventListener('keydown',function(e){if(e.key==='Escape'){document.querySelectorAll('.fiche-overlay.fiche-open').forEach(function(el){el.classList.remove('fiche-open');});document.body.style.overflow='';}});
1295function toggleProlong(id){var el=document.getElementById('prolong-'+id);if(el)el.classList.toggle('hidden');}
1296setTimeout(function(){document.querySelectorAll('.flash').forEach(function(f){f.style.animation='fOut 0.3s ease forwards';setTimeout(function(){f.remove();},300);});},5000);
1297
1298// ── DATE RETOUR MIN DYNAMIQUE ──
1299function updateMinRetour(matId, dureeMax) {
1300    var debut = document.getElementById('debut-' + matId);
1301    var retour = document.getElementById('retour-' + matId);
1302    if (!debut || !retour || !debut.value) return;
1303    var dtDebut = new Date(debut.value);
1304    // Min = même moment que le début (même jour autorisé, ex: 09:00 → 11:00)
1305    retour.min = debut.value;
1306    // Max = début + dureeMax jours
1307    var maxRetour = new Date(dtDebut.getTime() + dureeMax * 86400000);
1308    retour.max = maxRetour.toISOString().slice(0, 16);
1309    // Reset si valeur antérieure au début
1310    if (retour.value && new Date(retour.value) < dtDebut) { retour.value = debut.value; }
1311}
1312function updateMinRetourCreneau(matId, dureeMax) {
1313    var debut = document.getElementById('creneau-' + matId);
1314    var retour = document.getElementById('retour-creneau-' + matId);
1315    if (!debut || !retour || !debut.value) return;
1316    var dtDebut = new Date(debut.value);
1317    retour.min = debut.value;
1318    var maxRetour = new Date(dtDebut.getTime() + dureeMax * 86400000);
1319    retour.max = maxRetour.toISOString().slice(0, 16);
1320    if (retour.value && new Date(retour.value) < dtDebut) { retour.value = debut.value; }
1321}
1322
1323// ── PROBLEME FORM TOGGLE ──
1324function toggleProbleme(id) {
1325    var el = document.getElementById('probleme-' + id);
1326    if (!el) return;
1327    el.classList.toggle('hidden');
1328}
1329
1330// ── SECTIONS COLLAPSIBLES ──
1331function toggleSection(id) {
1332    var el = document.getElementById(id);
1333    if (!el) return;
1334    var isOpen = el.style.display !== 'none';
1335    el.style.display = isOpen ? 'none' : 'block';
1336    var arrow = document.getElementById('arrow-resa');
1337    if (arrow) arrow.textContent = isOpen ? '\u25b8' : '\u25be';
1338}
1339
1340// ── OFFLINE DETECTION ──
1341function updateOnlineStatus(){
1342    var banner=document.getElementById('offline-banner');
1343    if(!navigator.onLine){
1344        if(banner)banner.classList.add('show');
1345        document.body.classList.add('is-offline');
1346    } else {
1347        if(banner)banner.classList.remove('show');
1348        document.body.classList.remove('is-offline');
1349    }
1350}
1351window.addEventListener('online', updateOnlineStatus);
1352window.addEventListener('offline', updateOnlineStatus);
1353updateOnlineStatus();
1354
1355// ── HAMBURGER MOBILE ──
1356function toggleSidebar(){
1357    var sidebar=document.getElementById('sidebar');
1358    var overlay=document.getElementById('sidebar-overlay');
1359    if(sidebar){sidebar.classList.toggle('open');}
1360    if(overlay){overlay.classList.toggle('open');}
1361}
1362
1363// ── ONGLETS CRÉNEAU ──
1364function switchTab(matId, tab){
1365    ['emprunt','creneau'].forEach(function(t){
1366        var el=document.getElementById('tab-'+t+'-'+matId);
1367        if(el){if(t===tab)el.classList.remove('hidden');else el.classList.add('hidden');}
1368    });
1369}
1370
1371function toggleProlong(id){var el=document.getElementById('prolong-'+id);if(el)el.classList.toggle('hidden');}
1372// ── AUTO-OPEN FICHE ON ERROR (hash navigation) ──
1373document.addEventListener('DOMContentLoaded', function() {
1374    var hash = window.location.hash;
1375    if (hash && hash.startsWith('#fiche-')) {
1376        var ficheId = hash.substring(1); // e.g. "fiche-M3"
1377        var el = document.getElementById(ficheId);
1378        if (el) {
1379            el.classList.add('fiche-open');
1380            document.body.style.overflow = 'hidden';
1381            // Reset date inputs inside this fiche so user starts fresh
1382            el.querySelectorAll('input[type="datetime-local"]').forEach(function(inp) {
1383                inp.value = '';
1384            });
1385            // Scroll to error flash if visible
1386            var flash = document.querySelector('.flash-danger, .alert-danger');
1387            if (flash) flash.scrollIntoView({behavior:'smooth', block:'nearest'});
1388        }
1389        // Clean hash from URL without reload
1390        history.replaceState(null, '', window.location.pathname);
1391    }
1392});
1393
1394function toggleCasier(id) {
1395    var panel = document.getElementById('casier-'+id);
1396    if (!panel) return;
1397    panel.classList.toggle('hidden');
1398    if (!panel.classList.contains('hidden')) {
1399        var inp = document.getElementById('code-input-'+id);
1400        if (inp) { inp.value=''; inp.disabled=false; inp.style.borderColor=''; setTimeout(function(){inp.focus();},60); }
1401        var ok=document.getElementById('casier-ok-'+id), err=document.getElementById('casier-err-'+id);
1402        if(ok) ok.classList.add('hidden'); if(err) err.classList.add('hidden');
1403    }
1404}
1405function onCodeInput(id, inp) {
1406    inp.style.borderColor = inp.value.length === 6 ? 'var(--green)' : 'var(--border)';
1407}
1408function confirmerOuverture(id, codeCorrect) {
1409    var inp=document.getElementById('code-input-'+id);
1410    var ok=document.getElementById('casier-ok-'+id);
1411    var err=document.getElementById('casier-err-'+id);
1412    if (!inp || inp.value.length !== 6) { if(inp){inp.style.borderColor='var(--red)';inp.focus();} return; }
1413    if (inp.value === String(codeCorrect)) {
1414        inp.style.borderColor='var(--green)'; inp.disabled=true;
1415        if(ok) ok.classList.remove('hidden'); if(err) err.classList.add('hidden');
1416    } else {
1417        inp.style.borderColor='var(--red)';
1418        if(err) err.classList.remove('hidden'); if(ok) ok.classList.add('hidden');
1419        inp.value=''; setTimeout(function(){ inp.focus(); },50);
1420    }
1421}
1422function toggleProlong(id){var el=document.getElementById('prolong-'+id);if(el)el.classList.toggle('hidden');}
1423function openFiche(id){document.getElementById('fiche-'+id).classList.add('fiche-open');document.body.style.overflow='hidden';}
1424function closeFiche(id){document.getElementById('fiche-'+id).classList.remove('fiche-open');document.body.style.overflow='';}
1425document.addEventListener('keydown',function(e){if(e.key==='Escape'){document.querySelectorAll('.fiche-overlay.fiche-open').forEach(function(el){el.classList.remove('fiche-open');});document.body.style.overflow='';}});
1426
1427function toggleMsg(id) {
1428    var body = document.getElementById('msgbody-'+id);
1429    if (body) body.classList.toggle('hidden');
1430}
1431function switchMsgTab(tab) {
1432    var eleve = document.getElementById('compose-eleve');
1433    var admin = document.getElementById('compose-admin');
1434    var btnE  = document.getElementById('tab-eleve-btn');
1435    var btnA  = document.getElementById('tab-admin-btn');
1436    if (tab === 'eleve') {
1437        if(eleve) eleve.classList.remove('hidden');
1438        if(admin) admin.classList.add('hidden');
1439        if(btnE) btnE.classList.add('active');
1440        if(btnA) btnA.classList.remove('active');
1441    } else {
1442        if(admin) admin.classList.remove('hidden');
1443        if(eleve) eleve.classList.add('hidden');
1444        if(btnA) btnA.classList.add('active');
1445        if(btnE) btnE.classList.remove('active');
1446    }
1447}
1448
1449// ── PASSWORD TOGGLE ──
1450function togglePwd(id) {
1451    var el = document.getElementById(id);
1452    if (el) el.type = el.type === 'password' ? 'text' : 'password';
1453}
1454function togglePwdDisplay() {
1455    var el = document.getElementById('current_pwd_display');
1456    if (!el) return;
1457    var btn = document.getElementById('pwd-eye-btn');
1458    var real = el.getAttribute('data-val') || '';
1459    if (el.value === '••••••••') {
1460        el.value = real || '(non mémorisé — utilisez le formulaire ci-dessous)';
1461        el.style.letterSpacing = real ? '0.05em' : '0';
1462        el.style.fontFamily = real ? 'monospace' : 'Inter, sans-serif';
1463        el.style.fontSize = real ? '' : '0.75rem';
1464        if (btn) btn.textContent = '🙈';
1465    } else {
1466        el.value = '••••••••';
1467        el.style.letterSpacing = '0.2em';
1468        el.style.fontFamily = 'monospace';
1469        el.style.fontSize = '';
1470        if (btn) btn.textContent = '👁';
1471    }
1472}
1473
1474// ── CARD FORMATTING ──
1475function formatCardNum(el) {
1476    var v = el.value.replace(/\D/g,'').substring(0,16);
1477    el.value = v.replace(/(.{4})/g,'$1 ').trim();
1478}
1479function formatExp(el) {
1480    var v = el.value.replace(/\D/g,'').substring(0,4);
1481    if (v.length >= 2) v = v.substring(0,2) + '/' + v.substring(2);
1482    el.value = v;
1483}
1484function validateCard(form) {
1485    var num = form.card_num.value.replace(/\s/g,'');
1486    if (num.length < 12) { alert('Numéro de carte invalide.'); return false; }
1487    var exp = form.card_exp.value;
1488    if (!/^\d{2}\/\d{2}$/.test(exp)) { alert('Date d\'expiration invalide (MM/AA).'); return false; }
1489    var cvv = form.card_cvv.value;
1490    if (cvv.length < 3) { alert('CVV invalide.'); return false; }
1491    return true;
1492}
1493</script>
1494</body>
1495</html>

Design System — style.css

Feuille de style complète du projet. Organisée en modules : Design Tokens, Sidebar sombre, Cards matériel, Formulaires, Modales, Grille de casiers, Page paiement avec carte bancaire interactive et media queries responsive.

CSS VariablesFlexboxCSS GridResponsive1 388 lignes
style.css — 1 388 lignesCSS
   1/* ═══════════════════════════════════════════════════════
   2   LOANTRACK — INRACI  ·  Design System v3
   3   TFE 2026 — Enes Atmac
   4═══════════════════════════════════════════════════════ */
   5
   6:root {
   7    /* Palette */
   8    --bg: #f0f4f8;
   9    --surface: #ffffff;
  10    --surface2: #f4f7fb;
  11    --surface3: #e8eef5;
  12    --border: #d0dbe8;
  13    --border2: #b8c8d8;
  14    --text: #1a2332;
  15    --text2: #4a5a6a;
  16    --text3: #8a9aaa;
  17
  18    /* Accent & couleurs sémantiques */
  19    --accent: #2563eb;
  20    --accent-h: #1d4ed8;
  21    --accent-dim: rgba(37,99,235,0.08);
  22    --teal: #0891b2;
  23    --teal-dim: rgba(8,145,178,0.08);
  24    --green: #16a34a;
  25    --green-dim: rgba(22,163,74,0.08);
  26    --red: #dc2626;
  27    --red-dim: rgba(220,38,38,0.08);
  28    --orange: #ea580c;
  29    --orange-dim: rgba(234,88,12,0.08);
  30    --purple: #7c3aed;
  31    --purple-dim: rgba(124,58,237,0.08);
  32    --brown: #92400e;
  33    --brown-dim: rgba(146,64,14,0.08);
  34
  35    /* Coins & ombres */
  36    --radius: 8px;
  37    --radius-lg: 12px;
  38    --radius-xl: 16px;
  39    --radius-2xl: 20px;
  40    --shadow-xs: 0 1px 4px rgba(26,35,50,0.06);
  41    --shadow: 0 2px 10px rgba(26,35,50,0.08);
  42    --shadow-lg: 0 6px 28px rgba(26,35,50,0.13);
  43    --shadow-xl: 0 12px 48px rgba(26,35,50,0.18);
  44
  45    /* Sidebar */
  46    --sidebar-w: 272px;
  47    --sidebar-bg1: #0f172a;
  48    --sidebar-bg2: #1e293b;
  49}
  50
  51/* ── RESET & BASE ─────────────────────────────────── */
  52*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
  53
  54body {
  55    font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
  56    background: var(--bg);
  57    color: var(--text);
  58    min-height: 100vh;
  59    font-size: 14px;
  60    line-height: 1.6;
  61    -webkit-font-smoothing: antialiased;
  62}
  63a { text-decoration: none; color: inherit; }
  64.hidden { display: none !important; }
  65.app-wrap { display: flex; min-height: 100vh; }
  66
  67/* ── OFFLINE BANNER ───────────────────────────────── */
  68.offline-banner {
  69    display: none; position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
  70    background: var(--red); color: white; text-align: center;
  71    padding: 11px 20px; font-size: 0.875rem; font-weight: 700;
  72    box-shadow: 0 2px 16px rgba(0,0,0,0.3);
  73    letter-spacing: 0.01em;
  74}
  75.offline-banner.show { display: block; }
  76body.is-offline .main, body.is-offline .sidebar { opacity: 0.65; pointer-events: none; }
  77
  78/* ══════════════════════════════════════════════════
  79   SIDEBAR — Design sombre professionnel
  80══════════════════════════════════════════════════ */
  81.sidebar {
  82    width: var(--sidebar-w);
  83    background: linear-gradient(180deg, var(--sidebar-bg1) 0%, var(--sidebar-bg2) 100%);
  84    height: 100vh;
  85    position: fixed; top: 0; left: 0;
  86    display: flex; flex-direction: column;
  87    z-index: 100;
  88    box-shadow: 4px 0 32px rgba(0,0,0,0.25);
  89    border-right: 1px solid rgba(255,255,255,0.04);
  90}
  91
  92/* Logo / Brand */
  93.sidebar-brand {
  94    display: flex; align-items: center; gap: 13px;
  95    padding: 22px 20px 18px;
  96    border-bottom: 1px solid rgba(255,255,255,0.07);
  97}
  98.brand-logo {
  99    width: 40px; height: 40px; border-radius: 11px;
 100    background: linear-gradient(135deg, var(--accent), #1d4ed8);
 101    display: flex; align-items: center; justify-content: center;
 102    font-weight: 900; font-size: 0.95rem; color: white; flex-shrink: 0;
 103    box-shadow: 0 4px 14px rgba(37,99,235,0.45);
 104    letter-spacing: -0.03em;
 105}
 106.brand-name {
 107    font-size: 1.05rem; font-weight: 800; color: white;
 108    letter-spacing: -0.01em;
 109}
 110.brand-sub {
 111    font-size: 0.6rem; color: rgba(255,255,255,0.38);
 112    text-transform: uppercase; letter-spacing: 0.14em; margin-top: 1px;
 113}
 114.role-tag {
 115    margin-left: auto; font-size: 0.58rem; font-weight: 700;
 116    text-transform: uppercase; padding: 4px 9px;
 117    border-radius: 6px; letter-spacing: 0.06em;
 118}
 119.role-tag.admin { background: rgba(220,38,38,0.25); color: #fca5a5; border: 1px solid rgba(220,38,38,0.3); }
 120.role-tag.responsable { background: rgba(8,145,178,0.2); color: #7dd3fc; border: 1px solid rgba(8,145,178,0.3); }
 121
 122/* User card in sidebar */
 123.sidebar-user {
 124    display: flex; align-items: center; gap: 11px;
 125    padding: 14px 20px;
 126    border-bottom: 1px solid rgba(255,255,255,0.06);
 127    cursor: pointer;
 128    transition: background 0.18s;
 129    margin: 0;
 130}
 131.sidebar-user:hover, .sidebar-user.active-user {
 132    background: rgba(255,255,255,0.07);
 133}
 134.avatar {
 135    width: 36px; height: 36px; border-radius: 50%;
 136    background: rgba(255,255,255,0.14);
 137    border: 2px solid rgba(255,255,255,0.18);
 138    display: flex; align-items: center; justify-content: center;
 139    font-size: 0.78rem; font-weight: 700; color: white; flex-shrink: 0;
 140}
 141.avatar-admin { background: rgba(220,38,38,0.4) !important; border-color: rgba(220,38,38,0.5) !important; }
 142.avatar-responsable { background: rgba(8,145,178,0.4) !important; border-color: rgba(8,145,178,0.5) !important; }
 143.user-name {
 144    font-size: 0.855rem; font-weight: 600; color: rgba(255,255,255,0.92);
 145    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
 146}
 147.user-email {
 148    font-size: 0.68rem; color: rgba(255,255,255,0.38);
 149    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px;
 150}
 151
 152/* Nav items */
 153.nav-section { padding: 10px 0; flex: 1; overflow-y: auto; }
 154.nav-section::-webkit-scrollbar { width: 0; }
 155
 156.nav-label {
 157    display: block;
 158    padding: 14px 20px 6px;
 159    font-size: 0.6rem; font-weight: 700;
 160    text-transform: uppercase; letter-spacing: 0.16em;
 161    color: rgba(255,255,255,0.28);
 162}
 163
 164.nav-item {
 165    display: flex; align-items: center; gap: 11px;
 166    padding: 11px 20px;
 167    color: rgba(255,255,255,0.6);
 168    font-size: 0.875rem; font-weight: 500;
 169    transition: all 0.15s;
 170    border-left: 3px solid transparent;
 171    cursor: pointer;
 172    position: relative;
 173    margin: 1px 0;
 174}
 175.nav-item:hover {
 176    background: rgba(255,255,255,0.08);
 177    color: rgba(255,255,255,0.92);
 178    border-left-color: rgba(255,255,255,0.2);
 179}
 180.nav-item.active {
 181    background: linear-gradient(90deg, rgba(37,99,235,0.35), rgba(37,99,235,0.08));
 182    color: white;
 183    border-left-color: var(--accent);
 184    font-weight: 700;
 185}
 186.nav-item.active .ni { color: var(--accent); }
 187.nav-item.nav-out { color: rgba(255,130,130,0.75) !important; }
 188.nav-item.nav-out:hover { background: rgba(220,38,38,0.15) !important; color: #fca5a5 !important; }
 189.ni { font-size: 1.05rem; width: 22px; text-align: center; flex-shrink: 0; }
 190
 191.sidebar-footer {
 192    border-top: 1px solid rgba(255,255,255,0.06);
 193    padding: 8px 0 10px;
 194}
 195
 196/* ── HAMBURGER MOBILE ──────────────────────────── */
 197.hamburger {
 198    display: none; position: fixed; top: 14px; left: 14px; z-index: 300;
 199    background: var(--accent); border: none; border-radius: 9px;
 200    padding: 10px 12px; cursor: pointer; box-shadow: var(--shadow-lg);
 201}
 202.hamburger span { display: block; width: 20px; height: 2px; background: white; margin: 4px 0; border-radius: 2px; transition: all 0.3s; }
 203.sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99; }
 204
 205/* ══════════════════════════════════════════════════
 206   MAIN CONTENT
 207══════════════════════════════════════════════════ */
 208.main {
 209    margin-left: var(--sidebar-w);
 210    padding: 34px 40px;
 211    min-height: 100vh;
 212    width: calc(100% - var(--sidebar-w));
 213}
 214.page-header {
 215    margin-bottom: 28px;
 216    padding-bottom: 20px;
 217    border-bottom: 2px solid var(--border);
 218}
 219.page-title {
 220    font-size: 1.6rem; font-weight: 800; color: var(--text);
 221    letter-spacing: -0.02em; margin-bottom: 4px;
 222}
 223.page-sub { font-size: 0.875rem; color: var(--text2); }
 224.section-title {
 225    font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
 226    letter-spacing: 0.12em; color: var(--accent);
 227    margin: 28px 0 14px; padding-bottom: 8px;
 228    border-bottom: 2px solid var(--accent-dim);
 229}
 230
 231/* ── STATS CARDS ───────────────────────────────── */
 232.stats-row {
 233    display: grid;
 234    grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
 235    gap: 14px; margin-bottom: 28px;
 236}
 237.stat-card {
 238    background: var(--surface);
 239    border: 1px solid var(--border);
 240    border-radius: var(--radius-lg);
 241    padding: 20px 16px; text-align: center;
 242    box-shadow: var(--shadow-xs);
 243    transition: box-shadow 0.15s, transform 0.15s;
 244}
 245.stat-card:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
 246.stat-val { font-size: 2rem; font-weight: 800; margin-bottom: 4px; letter-spacing: -0.02em; }
 247.stat-lbl { font-size: 0.63rem; color: var(--text3); text-transform: uppercase; letter-spacing: 0.08em; }
 248.stat-card.stat-green .stat-val { color: var(--green); }
 249.stat-card.stat-red .stat-val { color: var(--red); }
 250.stat-card.stat-orange .stat-val { color: var(--orange); }
 251.stat-card.stat-blue .stat-val { color: var(--accent); }
 252.stat-card.stat-purple .stat-val { color: var(--purple); }
 253.stat-card.stat-teal .stat-val { color: var(--teal); }
 254.stat-card.stat-brown .stat-val { color: var(--brown); }
 255.stat-card.stat-gold .stat-val { color: #b45309; }
 256
 257/* ── BADGES ────────────────────────────────────── */
 258.badge {
 259    display: inline-flex; align-items: center;
 260    padding: 3px 10px; border-radius: 20px;
 261    font-size: 0.7rem; font-weight: 600;
 262}
 263.badge-green  { background: var(--green-dim);  color: var(--green);  border: 1px solid rgba(22,163,74,0.2); }
 264.badge-red    { background: var(--red-dim);    color: var(--red);    border: 1px solid rgba(220,38,38,0.2); }
 265.badge-blue   { background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(37,99,235,0.2); }
 266.badge-orange { background: var(--orange-dim); color: var(--orange); border: 1px solid rgba(234,88,12,0.2); }
 267.badge-grey   { background: var(--surface2);   color: var(--text2);  border: 1px solid var(--border); }
 268.badge-purple { background: var(--purple-dim); color: var(--purple); border: 1px solid rgba(124,58,237,0.2); }
 269.badge-brown  { background: var(--brown-dim);  color: var(--brown);  border: 1px solid rgba(146,64,14,0.2); }
 270.badge-teal   { background: var(--teal-dim);   color: var(--teal);   border: 1px solid rgba(8,145,178,0.2); }
 271/* compat */
 272.chip { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 20px; font-size: 0.72rem; font-weight: 600; }
 273.chip-green,.chip.badge-green { background: var(--green-dim);  color: var(--green);  border: 1px solid rgba(22,163,74,0.25); }
 274.chip-red,.chip.badge-red     { background: var(--red-dim);    color: var(--red);    border: 1px solid rgba(220,38,38,0.25); }
 275.chip-blue,.chip.badge-blue   { background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(37,99,235,0.25); }
 276.chip-orange,.chip.badge-orange { background: var(--orange-dim); color: var(--orange); border: 1px solid rgba(234,88,12,0.25); }
 277.chip-grey,.chip.badge-grey   { background: var(--surface2);   color: var(--text2);  border: 1px solid var(--border); }
 278.chip-purple,.chip.badge-purple { background: var(--purple-dim); color: var(--purple); border: 1px solid rgba(124,58,237,0.25); }
 279
 280/* ── HINT CLIQUABLE ────────────────────────────── */
 281.click-hint {
 282    display: flex; align-items: center; gap: 12px;
 283    padding: 13px 18px;
 284    background: linear-gradient(135deg, #eff6ff, #e0f2fe);
 285    border: 1px solid rgba(37,99,235,0.18);
 286    border-radius: var(--radius-lg);
 287    margin-bottom: 24px;
 288    font-size: 0.875rem; color: var(--accent); font-weight: 500;
 289    box-shadow: var(--shadow-xs);
 290}
 291
 292/* ── EMPRUNT ACTIF BANNER ──────────────────────── */
 293.active-loan-banner {
 294    display: flex; justify-content: space-between; align-items: center;
 295    gap: 16px; border-radius: var(--radius-lg);
 296    padding: 18px 22px; margin-bottom: 28px;
 297    border: 1px solid; box-shadow: var(--shadow);
 298}
 299.banner-ok     { background: #f0fdf4; border-color: rgba(22,163,74,0.3); }
 300.banner-danger { background: #fef2f2; border-color: rgba(220,38,38,0.3); }
 301.banner-purple { background: #faf5ff; border-color: rgba(124,58,237,0.3); }
 302.active-loan-left  { display: flex; align-items: center; gap: 16px; flex: 1; }
 303.active-loan-emoji { font-size: 2.2rem; flex-shrink: 0; }
 304.active-loan-tag   { font-size: 0.63rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text2); margin-bottom: 3px; font-weight: 600; }
 305.active-loan-nom   { font-size: 1rem; font-weight: 800; margin-bottom: 4px; }
 306.active-loan-meta  { font-size: 0.8rem; color: var(--text2); }
 307.active-loan-btn {
 308    background: var(--surface); border: 2px solid var(--border);
 309    color: var(--text2); padding: 9px 18px;
 310    border-radius: var(--radius); font-size: 0.82rem; font-weight: 600;
 311    transition: all 0.15s; white-space: nowrap; cursor: pointer;
 312}
 313.active-loan-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
 314
 315/* ══════════════════════════════════════════════════
 316   CATALOGUE MATÉRIEL
 317══════════════════════════════════════════════════ */
 318.cat-section { margin-bottom: 36px; }
 319.cat-header {
 320    display: flex; align-items: center; gap: 12px;
 321    margin-bottom: 16px; padding-bottom: 12px;
 322    border-bottom: 2px solid var(--accent);
 323}
 324.cat-title  { font-size: 1.05rem; font-weight: 800; color: var(--text); }
 325.cat-count  { font-size: 0.7rem; background: var(--accent-dim); color: var(--accent); padding: 3px 10px; border-radius: 12px; font-weight: 600; }
 326
 327.mat-catalog {
 328    display: grid;
 329    grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));
 330    gap: 16px;
 331}
 332.mat-card {
 333    background: var(--surface);
 334    border: 2px solid var(--border);
 335    border-radius: var(--radius-lg);
 336    padding: 18px 16px;
 337    cursor: pointer;
 338    transition: all 0.2s cubic-bezier(0.34,1.56,0.64,1);
 339    position: relative;
 340    box-shadow: var(--shadow-xs);
 341}
 342.mat-card:hover {
 343    border-color: var(--accent);
 344    box-shadow: 0 8px 28px rgba(37,99,235,0.16);
 345    transform: translateY(-4px);
 346}
 347.mat-card-indispo { opacity: 0.6; filter: grayscale(0.3); }
 348.mat-card-indispo:hover { border-color: var(--border); box-shadow: var(--shadow-xs); transform: none; }
 349.mat-card-top     { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
 350.mat-card-emoji   { font-size: 2.4rem; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.12)); }
 351.mat-card-badges  { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; }
 352.mat-card-nom     { font-size: 0.9rem; font-weight: 700; margin-bottom: 8px; line-height: 1.35; color: var(--text); }
 353.mat-card-footer  { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 4px; }
 354.mat-card-retour  {
 355    margin-top: 10px; font-size: 0.7rem; color: var(--orange);
 356    background: #fff7ed; border: 1px solid rgba(234,88,12,0.25);
 357    border-radius: 6px; padding: 5px 10px; font-weight: 500;
 358}
 359
 360/* ══════════════════════════════════════════════════
 361   MODAL FICHE MATÉRIEL
 362══════════════════════════════════════════════════ */
 363.fiche-overlay {
 364    display: none; position: fixed; inset: 0; z-index: 500;
 365    background: rgba(15,23,42,0.65);
 366    align-items: center; justify-content: center;
 367    padding: 20px;
 368    backdrop-filter: blur(6px);
 369}
 370.fiche-overlay.fiche-open { display: flex; }
 371
 372.fiche-modal {
 373    background: var(--surface);
 374    border: 1px solid var(--border);
 375    border-radius: var(--radius-2xl);
 376    width: 100%; max-width: 560px;
 377    max-height: 92vh; overflow-y: auto;
 378    position: relative;
 379    box-shadow: var(--shadow-xl);
 380}
 381.fiche-modal::-webkit-scrollbar { width: 4px; }
 382.fiche-modal::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
 383
 384.fiche-close {
 385    position: absolute; top: 16px; right: 16px;
 386    background: var(--surface2); border: 1px solid var(--border);
 387    color: var(--text3); width: 32px; height: 32px;
 388    border-radius: 50%; cursor: pointer; font-size: 0.82rem;
 389    display: flex; align-items: center; justify-content: center;
 390    z-index: 10; transition: all 0.15s;
 391}
 392.fiche-close:hover { background: var(--red-dim); color: var(--red); border-color: rgba(220,38,38,0.3); }
 393
 394.fiche-hero {
 395    display: flex; align-items: center; gap: 18px;
 396    padding: 26px 26px 20px;
 397    border-bottom: 1px solid var(--border);
 398    background: linear-gradient(135deg, #eff6ff 0%, #e0f2fe 100%);
 399    border-radius: var(--radius-2xl) var(--radius-2xl) 0 0;
 400}
 401.fiche-hero-emoji { font-size: 3.2rem; flex-shrink: 0; filter: drop-shadow(0 4px 8px rgba(0,0,0,0.15)); }
 402.fiche-nom   { font-size: 1.25rem; font-weight: 800; color: var(--text); margin-bottom: 10px; letter-spacing: -0.01em; }
 403.fiche-section { padding: 20px 26px 0; }
 404.fiche-section:last-child { padding-bottom: 26px; }
 405.fiche-section-title {
 406    font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
 407    letter-spacing: 0.12em; color: var(--text3);
 408    margin-bottom: 12px; padding-bottom: 7px;
 409    border-bottom: 1px solid var(--border);
 410}
 411.fiche-desc { font-size: 0.875rem; color: var(--text2); line-height: 1.75; }
 412.fiche-info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
 413.fiche-info-item {
 414    background: var(--surface2); border: 1px solid var(--border);
 415    border-radius: var(--radius); padding: 12px 14px;
 416}
 417.fiche-info-item span { display: block; font-size: 0.63rem; color: var(--text3); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px; }
 418.fiche-info-item strong { font-size: 0.9rem; font-weight: 700; }
 419
 420.fiche-action-section { padding-top: 0; padding-bottom: 26px; }
 421
 422/* Guide tip */
 423.fiche-guide-tip {
 424    display: flex; align-items: center; gap: 12px;
 425    background: linear-gradient(135deg, #eff6ff, #e0f2fe);
 426    border: 2px solid rgba(37,99,235,0.2);
 427    border-radius: var(--radius-lg);
 428    padding: 14px 16px; margin-bottom: 18px;
 429    font-size: 0.875rem; font-weight: 600; color: var(--accent);
 430    box-shadow: 0 2px 8px rgba(37,99,235,0.07);
 431}
 432
 433/* Step headers */
 434.fiche-step-header {
 435    display: flex; align-items: center; gap: 10px;
 436    margin: 18px 0 12px; padding: 11px 15px;
 437    background: linear-gradient(135deg, var(--accent), var(--accent-h));
 438    border-radius: var(--radius); color: white;
 439    box-shadow: 0 3px 10px rgba(37,99,235,0.25);
 440}
 441.fiche-step-num {
 442    width: 24px; height: 24px; background: rgba(255,255,255,0.22);
 443    border-radius: 50%; display: flex; align-items: center; justify-content: center;
 444    font-size: 0.78rem; font-weight: 800; flex-shrink: 0;
 445}
 446.fiche-step-label { font-size: 0.84rem; font-weight: 700; }
 447
 448/* Date inputs */
 449.date-row { display: flex; gap: 12px; margin-bottom: 12px; }
 450.date-row .field-group { flex: 1; }
 451.date-row .field-group label {
 452    font-size: 0.78rem; font-weight: 600; color: var(--text2);
 453    margin-bottom: 6px; display: block; text-transform: none; letter-spacing: 0;
 454}
 455.date-row .field-group input {
 456    padding: 11px 13px; border: 2px solid var(--border);
 457    border-radius: var(--radius); font-size: 0.875rem; width: 100%;
 458    background: var(--surface2); color: var(--text); outline: none; transition: border 0.15s;
 459}
 460.date-row .field-group input:focus { border-color: var(--accent); background: white; box-shadow: 0 0 0 3px var(--accent-dim); }
 461
 462/* Warning */
 463.fiche-warning {
 464    display: flex; align-items: flex-start; gap: 8px;
 465    font-size: 0.78rem; color: #92400e;
 466    background: #fffbeb; border: 1px solid #fcd34d;
 467    border-radius: var(--radius); padding: 10px 13px;
 468    margin-bottom: 14px; line-height: 1.55;
 469}
 470
 471/* CTA buttons */
 472.btn-emprunter {
 473    width: 100%; padding: 16px;
 474    background: linear-gradient(135deg, #16a34a, #15803d);
 475    color: white; border: none; border-radius: var(--radius-lg);
 476    font-size: 1rem; font-weight: 800; cursor: pointer;
 477    transition: all 0.18s; box-shadow: 0 4px 16px rgba(22,163,74,0.3);
 478    letter-spacing: 0.01em;
 479    display: flex; align-items: center; justify-content: center; gap: 8px;
 480    font-family: inherit;
 481}
 482.btn-emprunter:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(22,163,74,0.4); }
 483
 484.btn-creneau {
 485    width: 100%; padding: 14px;
 486    background: linear-gradient(135deg, var(--teal), #0e7490);
 487    color: white; border: none; border-radius: var(--radius-lg);
 488    font-size: 0.92rem; font-weight: 700; cursor: pointer;
 489    transition: all 0.18s; box-shadow: 0 3px 12px rgba(8,145,178,0.28);
 490    display: flex; align-items: center; justify-content: center; gap: 8px;
 491    font-family: inherit;
 492}
 493.btn-creneau:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(8,145,178,0.38); }
 494
 495.btn-reserver {
 496    width: 100%; padding: 14px;
 497    background: linear-gradient(135deg, var(--orange), #c2410c);
 498    color: white; border: none; border-radius: var(--radius-lg);
 499    font-size: 0.92rem; font-weight: 700; cursor: pointer;
 500    transition: all 0.18s; box-shadow: 0 3px 12px rgba(234,88,12,0.28);
 501    display: flex; align-items: center; justify-content: center; gap: 8px;
 502    font-family: inherit;
 503}
 504.btn-reserver:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(234,88,12,0.38); }
 505
 506/* Divider */
 507.fiche-divider {
 508    display: flex; align-items: center; gap: 12px;
 509    margin: 18px 0; color: var(--text3);
 510    font-size: 0.72rem; font-weight: 600;
 511    text-transform: uppercase; letter-spacing: 0.08em;
 512}
 513.fiche-divider::before, .fiche-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
 514
 515/* Alerts */
 516.fiche-alert {
 517    display: flex; justify-content: space-between; align-items: center;
 518    gap: 12px; border-radius: var(--radius-lg);
 519    padding: 14px 16px; font-size: 0.875rem; font-weight: 500;
 520}
 521.fiche-alert-info  { background: #eff6ff; border: 2px solid rgba(37,99,235,0.2); color: var(--accent); }
 522.fiche-alert-brown { background: #ecfeff; border: 2px solid rgba(8,145,178,0.2); color: var(--teal); }
 523
 524/* Retour prévu */
 525.fiche-retour-info {
 526    background: #fff7ed; border: 2px solid rgba(234,88,12,0.25);
 527    border-radius: var(--radius-lg); padding: 13px 16px;
 528    margin-bottom: 16px; font-size: 0.875rem; font-weight: 600;
 529    color: var(--orange); display: flex; align-items: center; gap: 10px;
 530}
 531
 532/* Créneau actif */
 533.creneau-actif {
 534    display: flex; align-items: center; gap: 14px;
 535    background: #ecfeff; border: 2px solid rgba(8,145,178,0.25);
 536    border-radius: var(--radius-lg); padding: 14px 16px;
 537    margin-bottom: 14px;
 538}
 539.creneau-actif-icon { font-size: 1.5rem; flex-shrink: 0; }
 540
 541/* ── EMPRUNT / HIST CARDS ──────────────────────── */
 542.amende-live { display: flex; justify-content: space-between; align-items: center; border-radius: var(--radius); padding: 9px 14px; margin-top: 8px; font-size: 0.82rem; background: var(--red-dim); border: 1px solid rgba(220,38,38,0.2); }
 543.amende-live.ok { background: var(--green-dim); border-color: rgba(22,163,74,0.2); }
 544.amende-live.warning { background: var(--orange-dim); border-color: rgba(234,88,12,0.2); }
 545.amende-row { display: flex; justify-content: space-between; align-items: center; background: var(--red-dim); border: 1px solid rgba(220,38,38,0.2); border-radius: 6px; padding: 8px 12px; margin-top: 8px; font-size: 0.82rem; }
 546.amende-row.paid { background: var(--green-dim); border-color: rgba(22,163,74,0.2); }
 547.info-banner { background: var(--accent-dim); border: 1px solid rgba(37,99,235,0.15); border-radius: 6px; padding: 8px 12px; font-size: 0.8rem; color: var(--accent); }
 548
 549/* ── HISTORIQUE ────────────────────────────────── */
 550.hist-card { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--border2); border-radius: var(--radius-lg); margin-bottom: 6px; overflow: hidden; box-shadow: var(--shadow-xs); }
 551.hist-card.hist-retard { border-left-color: var(--red); background: #fef9f9; }
 552.hist-card.hist-termine { opacity: 0.55; }
 553.hist-card.hist-verif { border-left-color: var(--purple); background: #faf5ff; }
 554.hist-inner { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding: 16px 20px; }
 555.hist-left { display: flex; align-items: flex-start; gap: 13px; flex: 1; }
 556.hist-emoji { font-size: 1.9rem; flex-shrink: 0; margin-top: 2px; }
 557.hist-nom  { font-size: 0.95rem; font-weight: 700; margin-bottom: 3px; }
 558.hist-ref  { font-size: 0.72rem; color: var(--text2); margin-bottom: 3px; font-family: 'Courier New', monospace; }
 559.hist-dates { font-size: 0.78rem; color: var(--text2); }
 560.hist-right { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; flex-shrink: 0; }
 561.hist-amende { font-size: 0.72rem; padding: 4px 10px; border-radius: 5px; font-weight: 600; }
 562.hist-amende.danger { background: var(--red-dim); color: var(--red); }
 563.hist-amende.warning { background: var(--orange-dim); color: var(--orange); }
 564.hist-amende.ok { background: var(--green-dim); color: var(--green); }
 565.hist-actions { display: flex; gap: 8px; padding: 10px 20px 12px; border-top: 1px solid var(--border); flex-wrap: wrap; background: var(--surface2); }
 566.hist-verif-banner { padding: 10px 20px 12px; background: var(--purple-dim); font-size: 0.82rem; color: var(--purple); border-top: 1px solid rgba(124,58,237,0.2); font-weight: 500; }
 567.hist-prolong-form { padding: 12px 20px 16px; background: var(--surface2); border-top: 1px solid var(--border); }
 568
 569/* ── RETOUR INSPECTION (responsable) ──────────── */
 570.alert-section { background: var(--surface); border: 2px solid var(--purple); border-radius: var(--radius-lg); margin-bottom: 24px; overflow: hidden; box-shadow: 0 0 0 4px var(--purple-dim); }
 571.alert-section-title { font-size: 0.84rem; font-weight: 700; padding: 14px 20px; background: var(--purple); color: white; display: flex; align-items: center; gap: 8px; }
 572.retour-card { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 16px 20px; border-bottom: 1px solid var(--border); flex-wrap: wrap; background: #faf5ff; }
 573.retour-card:last-child { border-bottom: none; }
 574.retour-nom  { font-weight: 700; font-size: 0.9rem; margin-bottom: 3px; }
 575.retour-meta { font-size: 0.75rem; color: var(--text2); }
 576.retour-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
 577
 578/* ── RÉSERVATIONS (responsable) ───────────────── */
 579.resa-section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); margin-bottom: 24px; overflow: hidden; box-shadow: var(--shadow-xs); }
 580.resa-section-title { font-size: 0.75rem; font-weight: 700; padding: 12px 20px; background: var(--teal-dim); color: var(--teal); border-bottom: 1px solid rgba(8,145,178,0.15); }
 581.resa-mat { padding: 14px 20px; border-bottom: 1px solid var(--border); }
 582.resa-mat:last-child { border-bottom: none; }
 583.resa-mat-nom { font-weight: 700; font-size: 0.88rem; margin-bottom: 8px; }
 584.resa-queue { display: flex; flex-wrap: wrap; gap: 6px; }
 585.resa-person { font-size: 0.75rem; background: var(--surface2); border: 1px solid var(--border); border-radius: 5px; padding: 4px 9px; }
 586.resa-person .pos { color: var(--accent); font-weight: 700; margin-right: 4px; }
 587
 588/* ── PAIEMENT ──────────────────────────────────── */
 589.payment-card { background: #fef2f2; border: 2px solid rgba(220,38,38,0.25); border-radius: var(--radius-lg); padding: 18px 22px; margin-bottom: 14px; box-shadow: var(--shadow-xs); }
 590.payment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
 591.payment-amount { font-size: 1.6rem; font-weight: 800; color: var(--red); letter-spacing: -0.02em; }
 592.payment-info { font-size: 0.8rem; color: var(--text2); margin-bottom: 6px; }
 593.payment-note { font-size: 0.75rem; color: var(--text3); background: var(--surface2); border-radius: 6px; padding: 8px 12px; margin-top: 8px; }
 594
 595/* ── REÇU ──────────────────────────────────────── */
 596.recu-wrap { max-width: 480px; margin: 0 auto; }
 597.recu-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-2xl); overflow: hidden; box-shadow: var(--shadow-lg); }
 598.recu-header { background: linear-gradient(135deg, var(--accent), #1d4ed8); padding: 34px 30px; text-align: center; }
 599.recu-check { width: 60px; height: 60px; background: rgba(255,255,255,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.7rem; color: white; margin: 0 auto 16px; box-shadow: 0 4px 16px rgba(0,0,0,0.15); }
 600.recu-header h2 { font-size: 1.25rem; font-weight: 800; color: white; margin-bottom: 4px; }
 601.recu-header p  { font-size: 0.78rem; color: rgba(255,255,255,0.6); }
 602.recu-body { padding: 26px; }
 603.recu-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--border); }
 604.recu-row:last-child { border-bottom: none; }
 605.recu-row span { font-size: 0.78rem; color: var(--text2); }
 606.recu-code-wrap { background: var(--surface2); border-radius: var(--radius-lg); padding: 22px; text-align: center; margin: 20px 0; border: 2px dashed var(--accent); }
 607.recu-code-label { font-size: 0.62rem; color: var(--text3); text-transform: uppercase; letter-spacing: 0.14em; margin-bottom: 12px; }
 608.recu-code { font-size: 3rem; font-weight: 800; letter-spacing: 0.45em; color: var(--accent); font-family: 'Courier New', monospace; }
 609.recu-notice { font-size: 0.72rem; color: var(--text2); text-align: center; padding: 12px 0; border-top: 1px solid var(--border); }
 610.recu-footer { padding: 18px 26px; background: var(--surface2); border-top: 1px solid var(--border); }
 611
 612/* ── PROFIL ────────────────────────────────────── */
 613.profil-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; max-width: 540px; box-shadow: var(--shadow); }
 614.profil-header { display: flex; align-items: center; gap: 18px; padding: 24px; border-bottom: 1px solid var(--border); background: linear-gradient(135deg, #eff6ff, #e0f2fe); }
 615.profil-avatar { width: 62px; height: 62px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.4rem; font-weight: 800; color: white; flex-shrink: 0; background: linear-gradient(135deg, var(--accent), #1d4ed8); box-shadow: 0 4px 16px rgba(37,99,235,0.3); }
 616.profil-avatar.avatar-admin { background: linear-gradient(135deg, var(--red), #b91c1c); box-shadow: 0 4px 16px rgba(220,38,38,0.3); }
 617.profil-avatar.avatar-responsable { background: linear-gradient(135deg, var(--teal), #0e7490); box-shadow: 0 4px 16px rgba(8,145,178,0.3); }
 618.profil-header h2 { font-size: 1.15rem; font-weight: 800; margin-bottom: 4px; }
 619.profil-header p  { font-size: 0.78rem; color: var(--text2); margin-bottom: 8px; }
 620.profil-row { display: flex; justify-content: space-between; align-items: center; padding: 14px 24px; border-bottom: 1px solid var(--border); font-size: 0.875rem; }
 621.profil-row:last-child { border-bottom: none; }
 622.profil-row span { color: var(--text2); }
 623
 624/* ── EMP CARDS ─────────────────────────────────── */
 625.emp-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 18px 20px; margin-bottom: 12px; box-shadow: var(--shadow-xs); }
 626.emp-card.retard { border-left: 4px solid var(--red); background: #fef9f9; }
 627.emp-card.termine { opacity: 0.55; }
 628.emp-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
 629.emp-title  { font-size: 0.95rem; font-weight: 700; }
 630.emp-meta   { font-size: 0.75rem; color: var(--text2); margin-top: 3px; }
 631.emp-dates  { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
 632
 633/* ── TABLE ─────────────────────────────────────── */
 634.table-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 24px; box-shadow: var(--shadow-xs); }
 635table { width: 100%; border-collapse: collapse; }
 636thead tr { background: linear-gradient(135deg, #eff6ff, #e0f2fe); }
 637th { padding: 12px 16px; text-align: left; font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); font-weight: 700; }
 638td { padding: 13px 16px; border-top: 1px solid var(--border); vertical-align: middle; font-size: 0.875rem; }
 639tr:hover td { background: var(--surface2); }
 640.tr-verif td { background: #faf5ff !important; }
 641
 642/* ── FORMS ─────────────────────────────────────── */
 643.form-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; box-shadow: var(--shadow-xs); }
 644.form-card h3 { font-size: 0.95rem; font-weight: 700; margin-bottom: 18px; color: var(--accent); }
 645.form-row { display: flex; gap: 12px; flex-wrap: wrap; }
 646.form-row .field-group { flex: 1; min-width: 140px; }
 647.field-group { margin-bottom: 16px; }
 648.field-group label { display: block; font-size: 0.75rem; font-weight: 600; color: var(--text2); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
 649.field-group input, .field-group select { width: 100%; padding: 10px 13px; background: var(--surface); border: 2px solid var(--border); border-radius: var(--radius); color: var(--text); font-family: inherit; font-size: 0.875rem; outline: none; transition: border 0.15s; }
 650.field-group input:focus, .field-group select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
 651.field-row { display: flex; gap: 10px; }
 652.field-row .field-group { flex: 1; }
 653.btn-primary { width: 100%; padding: 13px; background: linear-gradient(135deg, var(--accent), var(--accent-h)); color: white; border: none; border-radius: var(--radius); font-family: inherit; font-size: 0.9rem; font-weight: 700; cursor: pointer; transition: all 0.15s; box-shadow: 0 3px 10px rgba(37,99,235,0.28); }
 654.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 5px 18px rgba(37,99,235,0.38); }
 655.select-mini { padding: 5px 9px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 0.8rem; }
 656
 657/* ── BUTTONS ───────────────────────────────────── */
 658.btn-sm { padding: 7px 14px; border-radius: 7px; font-size: 0.78rem; font-weight: 600; border: none; cursor: pointer; font-family: inherit; transition: all 0.15s; }
 659.btn-sm:hover { opacity: 0.82; transform: translateY(-1px); }
 660.btn-sm-primary { background: var(--accent); color: white; }
 661.btn-sm-danger  { background: var(--red-dim); color: var(--red); border: 1px solid rgba(220,38,38,0.25); }
 662.btn-sm-success { background: var(--green-dim); color: var(--green); border: 1px solid rgba(22,163,74,0.25); }
 663.btn-sm-orange  { background: var(--orange-dim); color: var(--orange); border: 1px solid rgba(234,88,12,0.25); }
 664.btn-sm-teal    { background: var(--teal-dim); color: var(--teal); border: 1px solid rgba(8,145,178,0.25); }
 665.btn-sm-brown   { background: var(--brown-dim); color: var(--brown); border: 1px solid rgba(146,64,14,0.25); }
 666.btn-block { display: block; width: 100%; text-align: center; padding: 13px; background: linear-gradient(135deg, var(--accent), var(--accent-h)); color: white; border-radius: var(--radius); font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; transition: all 0.15s; font-family: inherit; }
 667.btn-block:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(37,99,235,0.32); }
 668
 669/* ── AUTH ──────────────────────────────────────── */
 670.auth-center { display: flex; min-height: 100vh; align-items: center; justify-content: center; background: linear-gradient(135deg, #dbeafe 0%, #e0f2fe 50%, #f0f9ff 100%); padding: 24px; }
 671.auth-panel { width: 100%; max-width: 410px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-2xl); padding: 40px; box-shadow: var(--shadow-xl); }
 672.auth-brand { text-align: center; margin-bottom: 10px; }
 673.auth-brand-logo { width: 56px; height: 56px; border-radius: 16px; background: linear-gradient(135deg, var(--accent), #1d4ed8); display: flex; align-items: center; justify-content: center; font-weight: 900; font-size: 1.05rem; color: white; margin: 0 auto 14px; box-shadow: 0 6px 20px rgba(37,99,235,0.35); }
 674.auth-brand-name { font-size: 1.4rem; font-weight: 900; color: var(--text); letter-spacing: -0.02em; }
 675.auth-brand-sub  { font-size: 0.7rem; color: var(--text3); text-transform: uppercase; letter-spacing: 0.12em; margin-top: 3px; }
 676.auth-title { font-size: 1.3rem; font-weight: 800; margin-bottom: 4px; margin-top: 28px; color: var(--text); }
 677.auth-sub { font-size: 0.875rem; color: var(--text2); margin-bottom: 22px; }
 678.auth-switch { margin-top: 18px; font-size: 0.82rem; color: var(--text2); text-align: center; }
 679.auth-switch span { color: var(--accent); cursor: pointer; font-weight: 600; }
 680.auth-switch span:hover { text-decoration: underline; }
 681.btn-google { width: 100%; padding: 12px 14px; background: var(--surface2); border: 2px solid var(--border); border-radius: var(--radius); color: var(--text); font-family: inherit; font-size: 0.875rem; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 10px; transition: all 0.15s; }
 682.btn-google:hover { border-color: var(--accent); background: var(--accent-dim); }
 683.divider { display: flex; align-items: center; gap: 12px; margin: 16px 0; color: var(--text3); font-size: 0.78rem; }
 684.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
 685
 686/* ── FLASH MESSAGES ────────────────────────────── */
 687.flash { position: fixed; top: 22px; left: 50%; transform: translateX(-50%); padding: 13px 24px; border-radius: var(--radius-lg); font-size: 0.875rem; font-weight: 600; z-index: 9999; box-shadow: var(--shadow-lg); cursor: pointer; display: flex; align-items: center; gap: 12px; animation: fDown 0.25s ease; white-space: nowrap; max-width: 90vw; }
 688.flash-success { background: #f0fdf4; border: 2px solid rgba(22,163,74,0.4); color: var(--green); }
 689.flash-danger  { background: #fef2f2; border: 2px solid rgba(220,38,38,0.4); color: var(--red); }
 690.flash-x { opacity: 0.45; font-size: 0.72rem; }
 691@keyframes fDown { from { opacity:0; transform: translateX(-50%) translateY(-12px); } to { opacity:1; transform: translateX(-50%) translateY(0); } }
 692@keyframes fOut  { to { opacity:0; transform: translateX(-50%) translateY(-12px); } }
 693
 694/* ── LOGS & EMPTY ──────────────────────────────── */
 695.log-row { display: flex; gap: 14px; align-items: flex-start; padding: 11px 20px; border-bottom: 1px solid var(--border); font-size: 0.78rem; }
 696.log-row:last-child { border-bottom: none; }
 697.log-time   { color: var(--text3); font-size: 0.67rem; min-width: 135px; flex-shrink: 0; font-family: 'Courier New', monospace; }
 698.log-action { font-weight: 700; min-width: 155px; flex-shrink: 0; }
 699.log-detail { color: var(--text2); }
 700.empty-box  { text-align: center; padding: 60px 24px; color: var(--text2); }
 701.empty-icon { font-size: 3rem; margin-bottom: 14px; }
 702.empty-box p { font-size: 0.9rem; margin-bottom: 14px; }
 703.empty-box a { color: var(--accent); font-size: 0.875rem; }
 704
 705/* ══════════════════════════════════════════════════
 706   RESPONSIVE — MOBILE
 707══════════════════════════════════════════════════ */
 708
 709/* Prevent horizontal overflow globally */
 710html, body { overflow-x: hidden; max-width: 100%; }
 711* { min-width: 0; }
 712
 713/* Tables : toujours scrollables horizontalement */
 714.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
 715table { min-width: 520px; }
 716
 717@media (max-width: 768px) {
 718    /* ── Hamburger & sidebar ── */
 719    .hamburger { display: flex; flex-direction: column; justify-content: center; }
 720    .sidebar {
 721        transform: translateX(-100%);
 722        transition: transform 0.3s ease;
 723        width: min(290px, 85vw);
 724    }
 725    .sidebar.open { transform: translateX(0); z-index: 200; }
 726    .sidebar-overlay.open { display: block; }
 727
 728    /* ── Main layout ── */
 729    .main { margin-left: 0 !important; padding: 72px 14px 28px; width: 100% !important; box-sizing: border-box; }
 730    .app-wrap { display: block; }
 731
 732    /* ── Page header ── */
 733    .page-title { font-size: 1.25rem; }
 734    .page-header { padding-bottom: 14px; margin-bottom: 18px; }
 735
 736    /* ── Stats grid ── */
 737    .stats-row { grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 18px; }
 738    .stat-card { padding: 14px 10px; }
 739    .stat-val  { font-size: 1.6rem; }
 740
 741    /* ── Catalogue matériel ── */
 742    .mat-catalog { grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 10px; }
 743    .mat-card { padding: 13px 12px; }
 744    .mat-card-emoji { font-size: 2rem; }
 745
 746    /* ── Modal fiche — slide du bas sur mobile ── */
 747    .fiche-overlay { align-items: flex-end; padding: 0; }
 748    .fiche-modal {
 749        max-width: 100%;
 750        width: 100%;
 751        max-height: 92vh;
 752        border-radius: var(--radius-xl) var(--radius-xl) 0 0;
 753    }
 754    .fiche-hero { padding: 20px 18px 16px; gap: 14px; }
 755    .fiche-hero-emoji { font-size: 2.4rem; }
 756    .fiche-nom  { font-size: 1.05rem; }
 757    .fiche-section { padding: 16px 16px 0; }
 758    .fiche-section:last-child { padding-bottom: 20px; }
 759    .fiche-info-grid { grid-template-columns: 1fr 1fr; gap: 8px; }
 760    .date-row { flex-direction: column; gap: 8px; }
 761    .fiche-close { top: 12px; right: 12px; }
 762
 763    /* ── Formulaires ── */
 764    .form-row { flex-direction: column; gap: 0; }
 765    .field-row { flex-direction: column; gap: 0; }
 766    .pay-form-row { flex-direction: column; }
 767    .form-card { padding: 18px 16px; }
 768
 769    /* ── Emprunt actif banner ── */
 770    .active-loan-banner { flex-direction: column; align-items: flex-start; gap: 12px; padding: 14px 16px; }
 771    .active-loan-btn { align-self: flex-start; }
 772
 773    /* ── Historique ── */
 774    .hist-inner { flex-direction: column; gap: 8px; padding: 14px 14px; }
 775    .hist-right { flex-direction: row; align-items: center; flex-wrap: wrap; gap: 6px; }
 776    .hist-emoji { font-size: 1.5rem; }
 777    .hist-actions { padding: 8px 14px 10px; gap: 6px; }
 778
 779    /* ── Retour cards ── */
 780    .retour-card { flex-direction: column; align-items: flex-start; gap: 10px; padding: 12px 14px; }
 781    .retour-actions { flex-wrap: wrap; }
 782
 783    /* ── Tables ── */
 784    .table-wrap { border-radius: var(--radius); }
 785    table { font-size: 0.75rem; min-width: 480px; }
 786    th, td { padding: 8px 10px; }
 787
 788    /* ── Logs ── */
 789    .log-row { flex-direction: column; gap: 3px; padding: 10px 14px; }
 790    .log-time { min-width: unset; font-size: 0.65rem; }
 791    .log-action { min-width: unset; }
 792
 793    /* ── Profil card ── */
 794    .profil-card { max-width: 100%; }
 795    .profil-header { flex-direction: column; text-align: center; gap: 12px; padding: 20px 16px; }
 796    .profil-row { padding: 12px 16px; font-size: 0.82rem; }
 797
 798    /* ── Messages ── */
 799    .msg-header { padding: 12px 14px; gap: 10px; }
 800    .msg-body   { padding: 0 14px 14px; }
 801    .msg-reply  { max-width: 95%; }
 802    .msg-compose-tabs { flex-wrap: wrap; }
 803
 804    /* ── Reçu ── */
 805    .recu-wrap { max-width: 100%; }
 806    .recu-header { padding: 24px 20px; }
 807    .recu-body   { padding: 18px 16px; }
 808    .recu-code   { font-size: 2.2rem; letter-spacing: 0.3em; }
 809    .recu-footer { padding: 14px 16px; }
 810
 811    /* ── Auth panel ── */
 812    .auth-center { padding: 16px; }
 813    .auth-panel  { padding: 28px 20px; }
 814
 815    /* ── Flash notifications ── */
 816    .flash { width: 92%; text-align: center; white-space: normal; left: 50%; }
 817
 818    /* ── Misc ── */
 819    .cat-header  { padding-bottom: 10px; }
 820    .emp-card    { padding: 14px 14px; }
 821    .emp-header  { flex-wrap: wrap; gap: 6px; }
 822    .click-hint  { font-size: 0.82rem; padding: 10px 14px; }
 823    .active-loan-meta { font-size: 0.76rem; }
 824    .btn-emprunter, .btn-creneau, .btn-reserver { padding: 14px; font-size: 0.9rem; }
 825    .retour-code-field { width: 140px; font-size: 1.2rem; }
 826    .auth-site-link { display: none; } /* Cache le lien site sur mobile (trop étroit) */
 827    .creneau-actif { flex-direction: column; gap: 8px; }
 828
 829    /* ── Retour code inline ── */
 830    .retour-code-inline { flex-direction: column; align-items: flex-start; gap: 8px; }
 831}
 832
 833@media (max-width: 480px) {
 834    /* Extra petit (iPhone SE etc.) */
 835    .mat-catalog { grid-template-columns: 1fr 1fr; gap: 8px; }
 836    .stats-row   { grid-template-columns: 1fr 1fr; gap: 8px; }
 837    .main { padding: 64px 10px 16px; }
 838    .fiche-info-grid { grid-template-columns: 1fr; }
 839    .fiche-hero { flex-direction: column; align-items: flex-start; }
 840    .page-title { font-size: 1.1rem; }
 841    .stat-val   { font-size: 1.4rem; }
 842    .sidebar-brand { padding: 18px 14px 14px; }
 843    .nav-item   { padding: 11px 14px; }
 844    .profil-header { padding: 16px 12px; }
 845    .form-card  { padding: 14px 12px; }
 846    .recu-code  { font-size: 1.8rem; letter-spacing: 0.2em; }
 847    .btn-sm     { padding: 7px 10px; font-size: 0.75rem; }
 848    /* Tableau : plus d'infos cachées sur très petit écran */
 849    .col-hide-xs { display: none !important; }
 850}
 851/* ══════════════════════════════════════════════════
 852   NEW ELEMENTS — v4 additions
 853══════════════════════════════════════════════════ */
 854
 855/* ── BACK BUTTON ───────────────────────────────── */
 856.btn-back {
 857    display: inline-flex; align-items: center; gap: 6px;
 858    padding: 6px 14px; background: var(--surface2);
 859    border: 1px solid var(--border); border-radius: var(--radius);
 860    font-size: 0.82rem; font-weight: 600; color: var(--text2);
 861    margin-bottom: 14px; cursor: pointer;
 862    transition: all 0.15s;
 863}
 864.btn-back:hover { background: var(--accent-dim); color: var(--accent); border-color: rgba(37,99,235,0.3); }
 865
 866/* ── PASSWORD TOGGLE WRAP ─────────────────────── */
 867.pwd-toggle-wrap { position: relative; display: flex; }
 868.pwd-toggle-wrap input { width: 100%; padding-right: 42px; }
 869.pwd-eye {
 870    position: absolute; right: 0; top: 0; bottom: 0;
 871    width: 38px; display: flex; align-items: center; justify-content: center;
 872    background: none; border: none; cursor: pointer;
 873    font-size: 1rem; color: var(--text3);
 874    border-radius: 0 var(--radius) var(--radius) 0;
 875}
 876.pwd-eye:hover { color: var(--accent); }
 877
 878/* ── PAYMENT FORM ──────────────────────────────── */
 879.pay-form-wrap {
 880    margin-top: 16px;
 881    padding: 20px;
 882    background: var(--surface2);
 883    border: 1px solid var(--border);
 884    border-radius: var(--radius-lg);
 885}
 886.pay-form-row {
 887    display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 0;
 888}
 889.pay-form-row .field-group { min-width: 110px; }
 890.pay-secure-note {
 891    font-size: 0.75rem; color: var(--text3);
 892    padding: 8px 12px; background: var(--surface3);
 893    border-radius: var(--radius); margin-top: 10px;
 894    border: 1px solid var(--border);
 895}
 896
 897/* ── WEBSITE RETURN LINK ──────────────────────── */
 898.btn-website-link {
 899    display: flex; align-items: center; justify-content: center; gap: 8px;
 900    padding: 9px 16px; margin-bottom: 16px;
 901    background: var(--surface2); border: 1px solid var(--border);
 902    border-radius: var(--radius); font-size: 0.82rem;
 903    color: var(--text2); font-weight: 500;
 904    transition: all 0.15s;
 905}
 906.btn-website-link:hover { background: var(--accent-dim); color: var(--accent); border-color: rgba(37,99,235,0.3); }
 907
 908/* ── CASIER DROPDOWN ──────────────────────────── */
 909.casier-status-libre   { color: var(--green); font-weight: 600; }
 910.casier-status-occupe  { color: var(--red); font-weight: 600; }
 911.casier-status-verif   { color: #92400e; font-weight: 600; }
 912
 913/* ── AUTH SITE LINK (top-right corner) ────────── */
 914.auth-site-link {
 915    position: fixed; top: 16px; right: 20px; z-index: 9999;
 916    display: inline-flex; align-items: center; gap: 6px;
 917    padding: 7px 14px;
 918    background: white; border: 1px solid var(--border);
 919    border-radius: 20px; font-size: 0.78rem; font-weight: 600;
 920    color: var(--accent); box-shadow: var(--shadow);
 921    transition: all 0.15s;
 922}
 923.auth-site-link:hover { background: var(--accent); color: white; border-color: var(--accent); }
 924
 925/* ── PASSWORD DIVIDER ─────────────────────────── */
 926.pwd-divider {
 927    font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
 928    letter-spacing: 0.1em; color: var(--accent);
 929    border-bottom: 2px solid var(--accent-dim);
 930    padding-bottom: 8px; margin: 20px 0 16px;
 931}
 932
 933/* ══════════════════════════════════════════════════
 934   MESSAGING SYSTEM
 935══════════════════════════════════════════════════ */
 936
 937/* Nav badge (unread count) */
 938.nav-badge {
 939    display: inline-flex; align-items: center; justify-content: center;
 940    background: var(--red); color: white;
 941    font-size: 0.6rem; font-weight: 800;
 942    min-width: 18px; height: 18px; border-radius: 9px;
 943    padding: 0 5px; margin-left: auto;
 944}
 945
 946/* Message card */
 947.msg-card {
 948    background: var(--surface);
 949    border: 1.5px solid var(--border);
 950    border-radius: var(--radius-lg);
 951    margin-bottom: 10px;
 952    overflow: hidden;
 953    box-shadow: var(--shadow-xs);
 954    transition: box-shadow 0.15s;
 955}
 956.msg-card:hover { box-shadow: var(--shadow); }
 957.msg-unread {
 958    border-color: rgba(37,99,235,0.4);
 959    background: linear-gradient(135deg, #f0f7ff, var(--surface));
 960}
 961
 962.msg-header {
 963    display: flex; align-items: center; gap: 14px;
 964    padding: 16px 20px; cursor: pointer;
 965    user-select: none;
 966}
 967.msg-header:hover { background: var(--surface2); }
 968
 969.msg-avatar {
 970    width: 38px; height: 38px; border-radius: 50%;
 971    background: linear-gradient(135deg, var(--accent), #1d4ed8);
 972    display: flex; align-items: center; justify-content: center;
 973    font-size: 0.9rem; font-weight: 800; color: white; flex-shrink: 0;
 974}
 975
 976.msg-meta { flex: 1; overflow: hidden; }
 977.msg-subject {
 978    font-size: 0.9rem; font-weight: 700; color: var(--text);
 979    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
 980}
 981.msg-from {
 982    font-size: 0.75rem; color: var(--text2); margin-top: 2px;
 983}
 984.msg-date { color: var(--text3); }
 985.msg-chevron {
 986    color: var(--text3); font-size: 1.2rem; transition: transform 0.2s;
 987    flex-shrink: 0;
 988}
 989
 990.msg-body {
 991    padding: 0 20px 18px;
 992    border-top: 1px solid var(--border);
 993}
 994.msg-content {
 995    font-size: 0.875rem; color: var(--text); line-height: 1.7;
 996    padding: 16px 0 12px;
 997    white-space: pre-wrap;
 998}
 999
1000/* Replies thread */
1001.msg-replies { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
1002.msg-reply {
1003    padding: 10px 14px;
1004    background: var(--surface2);
1005    border-radius: var(--radius);
1006    border-left: 3px solid var(--border2);
1007    max-width: 85%;
1008}
1009.msg-reply-mine {
1010    margin-left: auto;
1011    background: var(--accent-dim);
1012    border-left-color: var(--accent);
1013}
1014.msg-reply-author {
1015    font-size: 0.72rem; font-weight: 700; color: var(--text2);
1016    margin-bottom: 4px;
1017}
1018.msg-reply-text {
1019    font-size: 0.85rem; color: var(--text); line-height: 1.6;
1020    white-space: pre-wrap;
1021}
1022
1023.msg-reply-form { margin-top: 12px; }
1024
1025/* ── RETOUR CODE PANEL ─────────────────────────── */
1026.retour-code-panel {
1027    margin: 0 20px 16px;
1028    padding: 16px 18px;
1029    background: linear-gradient(135deg, #f0f7ff, #e8f4ff);
1030    border: 1.5px solid rgba(37,99,235,0.25);
1031    border-radius: var(--radius-lg);
1032}
1033.retour-code-header {
1034    display: flex; align-items: flex-start; gap: 12px;
1035    margin-bottom: 14px;
1036}
1037.retour-code-input-wrap {
1038    display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
1039}
1040
1041/* ── RETOUR CODE PANEL (inline, v2) ───────────── */
1042.retour-code-panel {
1043    padding: 12px 20px;
1044    background: linear-gradient(135deg, #f0f7ff, #e8f4ff);
1045    border-top: 1.5px solid rgba(37,99,235,0.18);
1046}
1047.retour-code-inline {
1048    display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
1049}
1050.retour-code-field {
1051    text-align: center;
1052    font-size: 1.4rem;
1053    font-family: monospace;
1054    letter-spacing: 0.3em;
1055    font-weight: 800;
1056    width: 160px;
1057    padding: 8px 12px;
1058    border: 2px solid var(--border);
1059    border-radius: var(--radius);
1060    background: white;
1061    color: var(--accent);
1062    transition: border-color 0.2s;
1063}
1064.retour-code-field:focus {
1065    outline: none;
1066    border-color: var(--accent);
1067    box-shadow: 0 0 0 3px var(--accent-dim);
1068}
1069
1070/* ── BTN-SM-BLUE ───────────────────────────────── */
1071.btn-sm-blue {
1072    background: var(--accent-dim); color: var(--accent);
1073    border: 1.5px solid rgba(37,99,235,0.3);
1074}
1075.btn-sm-blue:hover { background: var(--accent); color: white; }
1076
1077/* ── MESSAGE COMPOSE TABS ──────────────────────── */
1078.msg-compose-tabs {
1079    display: flex; gap: 8px; margin-bottom: 16px;
1080}
1081.msg-tab {
1082    padding: 8px 18px; border-radius: 20px;
1083    border: 1.5px solid var(--border);
1084    background: var(--surface2); color: var(--text2);
1085    font-size: 0.82rem; font-weight: 600;
1086    cursor: pointer; transition: all 0.15s;
1087    font-family: 'Inter', sans-serif;
1088}
1089.msg-tab:hover { border-color: var(--accent); color: var(--accent); }
1090.msg-tab.active {
1091    background: var(--accent); color: white;
1092    border-color: var(--accent);
1093}
1094/* ══════════════════════════════════════════════════
1095   NOUVEAUX COMPOSANTS
1096══════════════════════════════════════════════════ */
1097
1098/* ── Notification retours banner ── */
1099.notif-retour-banner {
1100    display: flex; align-items: center; justify-content: space-between;
1101    background: linear-gradient(135deg, #f5f0ff, #ede9fe);
1102    border: 1.5px solid rgba(124,58,237,0.35);
1103    border-radius: var(--radius-xl); padding: 14px 20px;
1104    margin-bottom: 10px; gap: 12px; flex-wrap: wrap;
1105}
1106.notif-dot {
1107    display: inline-block; width: 10px; height: 10px;
1108    background: var(--purple); border-radius: 50%;
1109    animation: pulse-dot 1.5s ease-in-out infinite; margin-right: 6px;
1110}
1111@keyframes pulse-dot {
1112    0%, 100% { opacity: 1; transform: scale(1); }
1113    50% { opacity: 0.5; transform: scale(1.4); }
1114}
1115.notif-retour-left { display: flex; align-items: center; font-size: 0.88rem; color: var(--text1); flex: 1; }
1116
1117/* ── Collapsible section ── */
1118.collapsible-section { margin-bottom: 20px; }
1119.collapsible-section.open { display: block !important; }
1120
1121/* ── Resa toggle card ── */
1122.resa-toggle-card {
1123    display: flex; align-items: center; justify-content: space-between;
1124    background: var(--surface); border: 1.5px solid var(--border);
1125    border-radius: var(--radius-xl); padding: 16px 20px; cursor: pointer;
1126    transition: var(--transition); margin-bottom: 0;
1127}
1128.resa-toggle-card:hover { border-color: var(--accent); background: var(--surface2); }
1129.resa-toggle-arrow { font-size: 1.1rem; color: var(--text3); transition: var(--transition); }
1130.resa-section {
1131    background: var(--surface); border: 1.5px solid var(--border);
1132    border-top: none; border-radius: 0 0 var(--radius-xl) var(--radius-xl);
1133    padding: 16px 20px; margin-bottom: 20px;
1134}
1135.resa-mat { margin-bottom: 14px; }
1136.resa-mat:last-child { margin-bottom: 0; }
1137.resa-mat-nom { font-weight: 700; font-size: 0.9rem; margin-bottom: 8px; }
1138.resa-queue { display: flex; gap: 8px; flex-wrap: wrap; }
1139.resa-person { display: flex; align-items: center; gap: 6px; background: var(--surface2); border-radius: var(--radius); padding: 5px 10px; font-size: 0.8rem; }
1140.resa-person .pos { font-weight: 700; color: var(--accent); }
1141
1142/* ── Casier grid (resp_materiel) ── */
1143.casier-grid {
1144    display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
1145    gap: 10px; margin-bottom: 24px;
1146}
1147.casier-btn {
1148    display: flex; flex-direction: column; align-items: center; justify-content: center;
1149    gap: 4px; padding: 14px 8px; border-radius: var(--radius-lg);
1150    border: 2px solid transparent; cursor: pointer; text-decoration: none;
1151    transition: var(--transition); font-family: var(--font);
1152}
1153.casier-btn-free {
1154    background: linear-gradient(135deg, #f0fdf4, #dcfce7);
1155    border-color: rgba(22,163,74,0.3); color: var(--green);
1156}
1157.casier-btn-free:hover { border-color: var(--green); transform: translateY(-2px); box-shadow: var(--shadow); }
1158.casier-btn-occupied {
1159    background: linear-gradient(135deg, #fff1f2, #ffe4e6);
1160    border-color: rgba(220,38,38,0.3); color: var(--red);
1161}
1162.casier-btn-occupied:hover { border-color: var(--red); transform: translateY(-2px); box-shadow: var(--shadow); }
1163.casier-id { font-weight: 800; font-size: 0.95rem; letter-spacing: 0.04em; }
1164.casier-emoji { font-size: 1.3rem; }
1165.casier-status { font-size: 0.7rem; }
1166
1167/* ── btn-sm-purple ── */
1168.btn-sm-purple {
1169    background: transparent; border: 1.5px solid var(--purple);
1170    color: var(--purple); border-radius: var(--radius); padding: 6px 12px;
1171    font-size: 0.78rem; font-weight: 600; cursor: pointer;
1172    font-family: var(--font); text-decoration: none; display: inline-flex;
1173    align-items: center; gap: 4px; transition: var(--transition);
1174}
1175.btn-sm-purple:hover { background: var(--purple); color: #fff; }
1176
1177/* ── Mobile adaptations for new components ── */
1178@media (max-width: 768px) {
1179    .notif-retour-banner { flex-direction: column; align-items: flex-start; }
1180    .casier-grid { grid-template-columns: repeat(auto-fill, minmax(75px, 1fr)); gap: 8px; }
1181    .casier-btn { padding: 10px 6px; }
1182    .casier-id { font-size: 0.82rem; }
1183    .casier-emoji { font-size: 1.1rem; }
1184    .resa-toggle-card { padding: 12px 16px; }
1185}
1186
1187/* ══════════════════════════════════════════════════
1188   STOCK BAR
1189══════════════════════════════════════════════════ */
1190.stock-bar-wrap {
1191    display: flex; align-items: center; gap: 8px;
1192    margin-top: 8px; padding-top: 8px;
1193    border-top: 1px solid var(--border);
1194}
1195.stock-bar {
1196    height: 5px; border-radius: 99px;
1197    flex-shrink: 0; min-width: 6px;
1198    transition: width 0.4s ease;
1199    max-width: 60px;
1200}
1201.stock-label {
1202    font-size: 0.65rem; color: var(--text3);
1203    font-family: var(--font); font-weight: 600;
1204    white-space: nowrap;
1205}
1206
1207/* ── Bouton payer maintenant (banner) ── */
1208.btn-pay-now {
1209    display: inline-flex; align-items: center; gap: 6px;
1210    background: var(--red); color: #fff; border-radius: var(--radius);
1211    padding: 8px 16px; font-size: 0.82rem; font-weight: 700;
1212    text-decoration: none; font-family: var(--font);
1213    transition: var(--transition); margin-top: 10px;
1214}
1215.btn-pay-now:hover { background: #b91c1c; transform: translateY(-1px); }
1216
1217/* ══════════════════════════════════════════════════
1218   PAGE PAIEMENT
1219══════════════════════════════════════════════════ */
1220.paiement-page { max-width: 880px; margin: 0 auto; }
1221.paiement-header {
1222    display: flex; align-items: center; justify-content: space-between;
1223    padding-bottom: 18px; margin-bottom: 24px;
1224    border-bottom: 1px solid var(--border);
1225}
1226.paiement-back {
1227    font-size: 0.85rem; color: var(--accent); text-decoration: none;
1228    font-family: var(--font); font-weight: 600;
1229}
1230.paiement-back:hover { text-decoration: underline; }
1231.paiement-logo {
1232    font-size: 0.82rem; color: var(--text3); font-family: var(--font);
1233    display: flex; align-items: center; gap: 6px;
1234}
1235.paiement-body {
1236    display: grid; grid-template-columns: 1fr 1.4fr; gap: 24px;
1237    align-items: start;
1238}
1239/* ── Récap ── */
1240.paiement-recap {
1241    background: var(--surface); border: 1.5px solid var(--border);
1242    border-radius: var(--radius-xl); padding: 24px; position: sticky; top: 80px;
1243}
1244.paiement-recap-title {
1245    font-weight: 800; font-size: 0.82rem; text-transform: uppercase;
1246    letter-spacing: 0.08em; color: var(--text3); margin-bottom: 18px;
1247    font-family: var(--font);
1248}
1249.paiement-recap-row {
1250    display: flex; justify-content: space-between; align-items: flex-start;
1251    padding: 10px 0; border-bottom: 1px solid var(--border);
1252    font-size: 0.85rem; font-family: var(--font); gap: 10px;
1253}
1254.paiement-recap-mat { font-weight: 600; text-align: right; }
1255.paiement-recap-total {
1256    display: flex; justify-content: space-between; align-items: center;
1257    padding-top: 16px; margin-top: 4px;
1258    font-weight: 800; font-size: 1rem; font-family: var(--font);
1259}
1260.paiement-total-amount { font-size: 1.6rem; color: var(--red); letter-spacing: -0.03em; }
1261.paiement-secure-badge {
1262    margin-top: 16px; padding: 10px 14px;
1263    background: rgba(22,163,74,0.08); border: 1px solid rgba(22,163,74,0.2);
1264    border-radius: var(--radius); font-size: 0.72rem; color: var(--green);
1265    text-align: center; font-family: var(--font); font-weight: 600;
1266}
1267/* ── Formulaire ── */
1268.paiement-form-wrap {
1269    background: var(--surface); border: 1.5px solid var(--border);
1270    border-radius: var(--radius-xl); padding: 28px;
1271}
1272.paiement-form-title {
1273    font-weight: 800; font-size: 1rem; margin-bottom: 20px; font-family: var(--font);
1274}
1275/* ── Card preview ── */
1276.paiement-card-preview {
1277    background: linear-gradient(135deg, #1a2332 0%, #2d3f5e 50%, #1e3a5f 100%);
1278    border-radius: 14px; padding: 22px 24px 18px;
1279    margin-bottom: 22px; position: relative; overflow: hidden;
1280    box-shadow: 0 8px 32px rgba(26,35,50,0.35);
1281    min-height: 130px;
1282}
1283.paiement-card-preview::before {
1284    content: ''; position: absolute; top: -30px; right: -30px;
1285    width: 120px; height: 120px; border-radius: 50%;
1286    background: rgba(255,255,255,0.06);
1287}
1288.paiement-card-preview::after {
1289    content: ''; position: absolute; bottom: -20px; left: 60px;
1290    width: 80px; height: 80px; border-radius: 50%;
1291    background: rgba(255,255,255,0.04);
1292}
1293.card-chip {
1294    font-size: 1.2rem; color: rgba(255,215,0,0.8); margin-bottom: 16px;
1295    letter-spacing: 2px;
1296}
1297.card-number-preview {
1298    font-family: 'Courier New', monospace; font-size: 1.1rem;
1299    color: rgba(255,255,255,0.9); letter-spacing: 0.2em; margin-bottom: 16px;
1300}
1301.card-bottom-preview {
1302    display: flex; justify-content: space-between;
1303    font-family: 'Courier New', monospace; font-size: 0.78rem;
1304    color: rgba(255,255,255,0.7);
1305}
1306.card-network {
1307    position: absolute; top: 18px; right: 20px;
1308    font-weight: 900; font-size: 1rem; color: rgba(255,255,255,0.9);
1309    font-style: italic; letter-spacing: 0.05em;
1310}
1311/* ── Submit ── */
1312.paiement-submit {
1313    width: 100%; padding: 15px; margin-top: 18px;
1314    background: linear-gradient(135deg, #1d4ed8, #2563eb);
1315    color: #fff; border: none; border-radius: var(--radius-lg);
1316    font-size: 1rem; font-weight: 700; cursor: pointer;
1317    font-family: var(--font); transition: var(--transition);
1318    box-shadow: 0 4px 16px rgba(37,99,235,0.35);
1319}
1320.paiement-submit:hover { background: linear-gradient(135deg, #1e40af, #1d4ed8); transform: translateY(-1px); }
1321.paiement-submit:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
1322.paiement-conditions {
1323    margin-top: 14px; padding: 12px 14px;
1324    background: var(--surface2); border-radius: var(--radius); border: 1px solid var(--border);
1325}
1326.paiement-footer-logos {
1327    display: flex; gap: 8px; justify-content: center; margin-top: 16px; flex-wrap: wrap;
1328}
1329.logo-badge {
1330    padding: 5px 10px; border: 1.5px solid var(--border);
1331    border-radius: 6px; font-size: 0.72rem; font-weight: 700;
1332    color: var(--text3); font-family: var(--font); letter-spacing: 0.04em;
1333}
1334@media (max-width: 768px) {
1335    .paiement-body { grid-template-columns: 1fr; }
1336    .paiement-recap { position: static; }
1337    .paiement-form-wrap { padding: 20px 16px; }
1338    .card-number-preview { font-size: 0.9rem; letter-spacing: 0.12em; }
1339    .paiement-total-amount { font-size: 1.3rem; }
1340}
1341
1342/* ══════════════════════════════════════════════════
1343   CARD TYPE SELECTOR
1344══════════════════════════════════════════════════ */
1345.card-type-selector {
1346    display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap;
1347}
1348.card-type-btn {
1349    flex: 1; min-width: 70px; padding: 10px 8px;
1350    border: 2px solid var(--border); border-radius: var(--radius-lg);
1351    background: var(--surface2); cursor: pointer; font-family: var(--font);
1352    font-size: 0.78rem; font-weight: 700; color: var(--text2);
1353    transition: var(--transition); display: flex; align-items: center;
1354    justify-content: center; gap: 4px;
1355}
1356.card-type-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--surface); }
1357.card-type-btn.active {
1358    border-color: var(--accent); background: rgba(99,102,241,0.08);
1359    color: var(--accent);
1360}
1361.card-type-logo { font-weight: 900; letter-spacing: 0.02em; }
1362
1363/* ── Bouton payer dans historique ── */
1364.btn-pay-hist {
1365    display: inline-flex; align-items: center; gap: 5px;
1366    background: var(--red); color: #fff; border-radius: var(--radius);
1367    padding: 5px 12px; font-size: 0.75rem; font-weight: 700;
1368    text-decoration: none; font-family: var(--font);
1369    transition: var(--transition); margin-top: 5px;
1370}
1371.btn-pay-hist:hover { background: #b91c1c; }
1372
1373/* ── Max emprunts warning ── */
1374.max-emprunts-warn {
1375    background: linear-gradient(135deg,#fffbeb,#fef3c7);
1376    border: 1.5px solid rgba(245,158,11,0.35);
1377    border-radius: var(--radius-xl); padding: 14px 18px;
1378    font-size: 0.85rem; color: var(--text1); margin-bottom: 18px;
1379    font-family: var(--font);
1380}
1381
1382/* ── card-network fix for selectCard ── */
1383#card-network { transition: color 0.2s ease; }
1384
1385@media (max-width: 480px) {
1386    .card-type-selector { gap: 6px; }
1387    .card-type-btn { min-width: 60px; font-size: 0.72rem; padding: 8px 6px; }
1388}

Firmware ESP32 — Casier connecté

Code complet du microcontrôleur. Polling HTTPS toutes les 3s vers Flask, lecture du capteur infrarouge break beam avec debounce sur 5 lectures consécutives, ouverture temporisée du relais (3s), envoi d'état heartbeat toutes les 15s, watchdog Wi-Fi pour reconnexion automatique. La broche GPIO 34 nécessite une résistance pull-up externe de 10kΩ.

C++ · Arduino IDEESP32 Freenove WROVERWiFiClientSecureHTTPClientArduinoJson195 lignes
main_firmware.ino — 195 lignesC++ · Arduino
   1// ================================================================
   2//  LOAN TRACK — Firmware ESP32 Casier Connecté
   3//  TFE 2026 — Enes Atmac · INRACI
   4//  Architecture : Polling HTTPS + Capteur IR + Relais 12V
   5// ================================================================
   6
   7#include <WiFi.h>
   8#include <WiFiClientSecure.h>
   9#include <HTTPClient.h>
  10#include <ArduinoJson.h>
  11
  12// ── CONFIGURATION RÉSEAU & API ──────────────────────────────────
  13const char* WIFI_SSID     = "TON_SSID";
  14const char* WIFI_PASSWORD = "TON_MOT_DE_PASSE";
  15const char* URL_BASE      = "https://COMPTE.pythonanywhere.com";
  16const char* API_SECRET    = "LOANTRACK_SECRET_2024";
  17const char* CASIER_ID     = "A01";
  18
  19// ── BROCHES GPIO ─────────────────────────────────────────────────
  20const int PIN_BEAM   = 34;   // Récepteur break beam (pull-up externe 10kΩ obligatoire)
  21const int PIN_RELAIS = 25;   // Commande relais monocanal
  22const int PIN_LED    = 2;    // LED interne (indicateur statut)
  23
  24// ── LOGIQUE PHYSIQUE (ajustable selon câblage) ───────────────────
  25const bool BEAM_PRESENT_QUAND_HIGH = true;   // Signal HIGH = faisceau intact = objet absent
  26const bool RELAIS_ACTIF_QUAND_LOW  = true;   // Ce relais s'active sur signal LOW
  27
  28// ── TIMING (millisecondes) ───────────────────────────────────────
  29const unsigned long DUREE_OUVERTURE    = 3000;   // Durée d'ouverture du verrou
  30const unsigned long INTERVAL_POLL      = 3000;   // Fréquence interrogation serveur
  31const unsigned long INTERVAL_HEARTBEAT = 15000;  // Envoi état périodique
  32const int           DEBOUNCE_BEAM      = 5;       // Nb lectures consécutives pour valider
  33
  34// ── VARIABLES D'ÉTAT ─────────────────────────────────────────────
  35bool materiel_present = false;
  36bool relais_actif     = false;
  37int  debounce_count   = 0;
  38unsigned long ts_relais    = 0;
  39unsigned long ts_poll      = 0;
  40unsigned long ts_heartbeat = 0;
  41
  42WiFiClientSecure clientSecure;
  43
  44// ── CONNECTER WIFI ───────────────────────────────────────────────
  45void connecterWifi() {
  46    Serial.print("[WiFi] Connexion à ");
  47    Serial.println(WIFI_SSID);
  48    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  49    unsigned long debut = millis();
  50    while (WiFi.status() != WL_CONNECTED && millis() - debut < 15000) {
  51        delay(500); Serial.print(".");
  52    }
  53    if (WiFi.status() == WL_CONNECTED) {
  54        Serial.println("\n[WiFi] Connecté. IP : " + WiFi.localIP().toString());
  55        digitalWrite(PIN_LED, HIGH);
  56    } else {
  57        Serial.println("\n[WiFi] Échec de connexion.");
  58        digitalWrite(PIN_LED, LOW);
  59    }
  60}
  61
  62// ── LIRE CAPTEUR IR ──────────────────────────────────────────────
  63bool lireBeam() {
  64    int etat = digitalRead(PIN_BEAM);
  65    // GPIO34 = Input-only, nécessite pull-up externe 10kΩ
  66    // HIGH = faisceau intact (rien dans casier)
  67    // LOW  = faisceau coupé (objet présent)
  68    return BEAM_PRESENT_QUAND_HIGH ? (etat == HIGH) : (etat == LOW);
  69}
  70
  71// ── DEBOUNCE CAPTEUR (5 lectures consécutives) ───────────────────
  72void lireBeam_debounce() {
  73    bool lecture = lireBeam();
  74    if (lecture == materiel_present) {
  75        debounce_count = 0;
  76        return;
  77    }
  78    if (++debounce_count >= DEBOUNCE_BEAM) {
  79        materiel_present = lecture;
  80        debounce_count   = 0;
  81        Serial.println(materiel_present ? "[IR] Objet détecté" : "[IR] Casier vide");
  82        envoyerEtat();
  83    }
  84}
  85
  86// ── CONTRÔLE RELAIS ──────────────────────────────────────────────
  87void relaisOn() {
  88    digitalWrite(PIN_RELAIS, RELAIS_ACTIF_QUAND_LOW ? LOW : HIGH);
  89    relais_actif = true;
  90    Serial.println("[RELAIS] Ouverture verrou");
  91}
  92void relaisOff() {
  93    digitalWrite(PIN_RELAIS, RELAIS_ACTIF_QUAND_LOW ? HIGH : LOW);
  94    relais_actif = false;
  95    Serial.println("[RELAIS] Verrouillage");
  96}
  97
  98// ── OUVRIR CASIER (ouverture temporisée 3s) ──────────────────────
  99void ouvrirCasier() {
 100    if (relais_actif) return;
 101    relaisOn();
 102    ts_relais = millis();
 103}
 104
 105// ── CONFIRMER OUVERTURE AU SERVEUR ───────────────────────────────
 106void confirmerOuverture(String commande_id) {
 107    if (WiFi.status() != WL_CONNECTED) return;
 108    HTTPClient http;
 109    http.begin(clientSecure, String(URL_BASE) + "/api/casier/" + CASIER_ID + "/confirmer");
 110    http.addHeader("Content-Type", "application/json");
 111    http.addHeader("X-API-Secret", API_SECRET);
 112    String payload = "{\"commande_id\":\"" + commande_id + "\"}";
 113    http.POST(payload);
 114    http.end();
 115}
 116
 117// ── ENVOYER ÉTAT CAPTEUR AU SERVEUR ─────────────────────────────
 118void envoyerEtat() {
 119    if (WiFi.status() != WL_CONNECTED) return;
 120    HTTPClient http;
 121    http.begin(clientSecure, String(URL_BASE) + "/api/casier/" + CASIER_ID + "/etat");
 122    http.addHeader("Content-Type", "application/json");
 123    http.addHeader("X-API-Secret", API_SECRET);
 124    String payload = "{\"materiel_present\":" + String(materiel_present ? "true" : "false") + "}";
 125    http.POST(payload);
 126    http.end();
 127    Serial.println("[ETAT] Envoyé : " + payload);
 128}
 129
 130// ── POLL SERVEUR (toutes les 3s) ─────────────────────────────────
 131void verifierCommande() {
 132    if (WiFi.status() != WL_CONNECTED) return;
 133    HTTPClient http;
 134    http.begin(clientSecure, String(URL_BASE) + "/api/casier/" + CASIER_ID + "/commande");
 135    http.addHeader("X-API-Secret", API_SECRET);
 136    int code = http.GET();
 137    if (code == 200) {
 138        StaticJsonDocument<200> doc;
 139        if (!deserializeJson(doc, http.getString())) {
 140            if (doc["ouvrir"].as<bool>()) {
 141                ouvrirCasier();
 142                confirmerOuverture(doc["commande_id"].as<String>());
 143            }
 144        }
 145    }
 146    http.end();
 147}
 148
 149// ── SETUP ────────────────────────────────────────────────────────
 150void setup() {
 151    Serial.begin(115200);
 152    pinMode(PIN_BEAM,   INPUT);
 153    pinMode(PIN_RELAIS, OUTPUT);
 154    pinMode(PIN_LED,    OUTPUT);
 155    relaisOff();
 156    clientSecure.setInsecure();  // Désactive vérif certificat (dev)
 157    connecterWifi();
 158    materiel_present = lireBeam();
 159    Serial.println("[SETUP] Casier prêt. ID : " + String(CASIER_ID));
 160}
 161
 162// ── LOOP PRINCIPALE (non bloquante via millis) ───────────────────
 163void loop() {
 164    unsigned long now = millis();
 165
 166    // 1. Lecture capteur avec debounce
 167    lireBeam_debounce();
 168
 169    // 2. Fermeture automatique après 3 secondes
 170    if (relais_actif && (now - ts_relais >= DUREE_OUVERTURE)) {
 171        relaisOff();
 172        envoyerEtat();
 173    }
 174
 175    // 3. Interrogation serveur toutes les 3s
 176    if (now - ts_poll >= INTERVAL_POLL) {
 177        ts_poll = now;
 178        verifierCommande();
 179    }
 180
 181    // 4. Heartbeat toutes les 15s
 182    if (now - ts_heartbeat >= INTERVAL_HEARTBEAT) {
 183        ts_heartbeat = now;
 184        envoyerEtat();
 185    }
 186
 187    // 5. Watchdog Wi-Fi
 188    if (WiFi.status() != WL_CONNECTED) {
 189        digitalWrite(PIN_LED, LOW);
 190        Serial.println("[WiFi] Reconnexion...");
 191        connecterWifi();
 192    }
 193
 194    delay(50);
 195}

Scripts de test Arduino isolés

Deux sketches de diagnostic conçus pour valider chaque composant indépendamment avant l'intégration. Permettent d'observer le comportement électrique brut dans le moniteur série Arduino IDE et de détecter les inversions de logique ou instabilités de signal.

C++ · ArduinoGPIO 34 — Input-onlyPull-up externe 10kΩGPIO 25 — Relais
test_infrarouge.ino — 70 lignesC++ · Arduino
   1/*
   2 * ═══════════════════════════════════════════════════════════════
   3 *  TEST ISOLÉ — BREAK BEAM INFRAROUGE (Adafruit)
   4 *  Casier connecté TFE — Akuma-Dono
   5 *
   6 *  But : tester UNIQUEMENT le capteur de présence à faisceau IR,
   7 *        sans WiFi, sans relais. Permet de valider :
   8 *          - que le faisceau est bien détecté
   9 *          - que la coupure du faisceau est détectée
  10 *          - quelle est la bonne logique (HIGH ou LOW = coupé)
  11 *
  12 *  CÂBLAGE :
  13 *    ÉMETTEUR (2 fils)   : Rouge → 3.3V , Noir → GND
  14 *    RÉCEPTEUR (3 fils)  : Rouge → 3.3V , Noir → GND , Blanc → GPIO 34
  15 *    + résistance pull-up 10kΩ entre le fil blanc (signal) et 3.3V
  16 *
  17 *  ⚠️ ALIMENTER EN 3.3V, PAS 5V (le GPIO 34 ne tolère pas le 5V)
  18 *
  19 *  UTILISATION :
  20 *    1. Flasher ce sketch
  21 *    2. Ouvrir le moniteur série (115200 bauds)
  22 *    3. Passer la main entre l'émetteur et le récepteur
  23 *    4. Observer le passage INTACT <-> COUPÉ
  24 *    5. Si l'état est inversé, changer BEAM_COUPE_QUAND_LOW
  25 * ═══════════════════════════════════════════════════════════════
  26 */
  27
  28const int PIN_BEAM = 34;   // signal du récepteur (input-only, pull-up externe)
  29
  30// Sur le break beam Adafruit : faisceau intact → signal HIGH,
  31// faisceau coupé → signal LOW. Donc "coupé quand LOW" = true par défaut.
  32// Si le comportement observé est inversé, mets false.
  33const bool BEAM_COUPE_QUAND_LOW = true;
  34
  35bool faisceauCoupe() {
  36    int etat = digitalRead(PIN_BEAM);
  37    return BEAM_COUPE_QUAND_LOW ? (etat == LOW) : (etat == HIGH);
  38}
  39
  40bool dernierEtat = false;
  41
  42void setup() {
  43    Serial.begin(115200);
  44    delay(500);
  45    Serial.println("\n=== TEST BREAK BEAM IR ===");
  46    Serial.printf("Pin signal : GPIO %d\n", PIN_BEAM);
  47    Serial.println("Passe la main entre emetteur et recepteur.\n");
  48
  49    pinMode(PIN_BEAM, INPUT);   // pull-up externe 10k obligatoire
  50
  51    dernierEtat = faisceauCoupe();
  52    Serial.printf("Etat initial : %s\n",
  53                  dernierEtat ? "COUPE (objet present)" : "INTACT (rien)");
  54}
  55
  56void loop() {
  57    bool coupe = faisceauCoupe();
  58
  59    // Afficher seulement quand l'état change (évite le spam)
  60    if (coupe != dernierEtat) {
  61        dernierEtat = coupe;
  62        if (coupe) {
  63            Serial.println(">>> FAISCEAU COUPE  → objet present dans le casier");
  64        } else {
  65            Serial.println("<<< FAISCEAU INTACT → casier vide");
  66        }
  67    }
  68
  69    delay(100);   // 10 lectures par seconde
  70}
  71
test_relais.ino — 64 lignesC++ · Arduino
   1/*
   2 * ═══════════════════════════════════════════════════════════════
   3 *  TEST ISOLÉ — MODULE RELAIS
   4 *  Casier connecté TFE — Akuma-Dono
   5 *
   6 *  But : tester UNIQUEMENT le relais (et le verrou s'il est câblé),
   7 *        sans WiFi, sans capteur. Permet de valider :
   8 *          - que le relais "clique" bien
   9 *          - que la logique HIGH/LOW est correcte
  10 *          - que le verrou 12V s'ouvre quand le relais est actif
  11 *
  12 *  CÂBLAGE :
  13 *    Relais VCC → 5V (VIN)
  14 *    Relais GND → GND
  15 *    Relais IN  → GPIO 25
  16 *    (Relais COM → 12V+ , NO → Verrou+ si on teste le verrou)
  17 *
  18 *  UTILISATION :
  19 *    1. Flasher ce sketch
  20 *    2. Ouvrir le moniteur série (115200 bauds)
  21 *    3. Observer : le relais doit cliquer toutes les 3 secondes
  22 *    4. Si le relais ne réagit pas, changer RELAIS_ACTIF_QUAND_LOW
  23 * ═══════════════════════════════════════════════════════════════
  24 */
  25
  26const int  PIN_RELAIS = 25;
  27
  28// Beaucoup de modules relais sont "actif au niveau bas" (LOW = activé).
  29// Si le relais ne clique pas avec true, mets false (et inversement).
  30const bool RELAIS_ACTIF_QUAND_LOW = true;
  31
  32void relaisOn() {
  33    digitalWrite(PIN_RELAIS, RELAIS_ACTIF_QUAND_LOW ? LOW : HIGH);
  34}
  35void relaisOff() {
  36    digitalWrite(PIN_RELAIS, RELAIS_ACTIF_QUAND_LOW ? HIGH : LOW);
  37}
  38
  39void setup() {
  40    Serial.begin(115200);
  41    delay(500);
  42    Serial.println("\n=== TEST RELAIS ===");
  43    Serial.printf("Pin relais : GPIO %d\n", PIN_RELAIS);
  44    Serial.printf("Logique    : actif quand %s\n",
  45                  RELAIS_ACTIF_QUAND_LOW ? "LOW" : "HIGH");
  46
  47    pinMode(PIN_RELAIS, OUTPUT);
  48    relaisOff();   // sécurité : verrou fermé au démarrage
  49    Serial.println("Demarrage : relais OFF\n");
  50}
  51
  52void loop() {
  53    // Activation
  54    Serial.println(">>> Relais ON (le verrou doit s'ouvrir)");
  55    relaisOn();
  56    delay(1000);   // maintenu 1 seconde
  57
  58    // Désactivation
  59    Serial.println("<<< Relais OFF");
  60    relaisOff();
  61    delay(3000);   // pause 3 secondes avant le prochain cycle
  62
  63    Serial.println("---");
  64}
  65

Script de sauvegarde — PowerShell

Sauvegarde automatisée de l'intégralité du projet (code source, fichiers 3D Fusion 360, documentation) dans une archive ZIP horodatée. Détecte et copie automatiquement sur clé USB externe. Conserve les 10 dernières versions et génère un fichier README.

PowerShellCompress-ArchiveBackup horodatéCopie USB auto58 lignes
backup_tfe.ps1 — 58 lignesPowerShell
   1# ================================================================
   2#  LOAN TRACK — Script de sauvegarde automatisée (PowerShell)
   3#  TFE 2026 — Enes Atmac · INRACI
   4# ================================================================
   5
   6# --- CONFIGURATION ---
   7$SOURCE        = "C:\Users\$env:USERNAME\Documents\Projet_Casier_TFE"
   8$BACKUP_LOCAL  = "C:\Users\$env:USERNAME\Documents\Backups_TFE"
   9$MAX_BACKUPS   = 10
  10$DATE          = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
  11$NOM_BACKUP    = "Casier_TFE_backup_$DATE"
  12
  13# --- CRÉATION DU DOSSIER DE SAUVEGARDE LOCAL ---
  14if (-not (Test-Path $BACKUP_LOCAL)) {
  15    New-Item -ItemType Directory -Path $BACKUP_LOCAL | Out-Null
  16    Write-Host "[INFO] Dossier créé : $BACKUP_LOCAL"
  17}
  18
  19# --- COMPRESSION ZIP HORODATÉE ---
  20$cheminZip = Join-Path $BACKUP_LOCAL "$NOM_BACKUP.zip"
  21Write-Host "[INFO] Compression en cours..."
  22Compress-Archive -Path "$SOURCE\*" `
  23    -DestinationPath $cheminZip `
  24    -CompressionLevel Optimal `
  25    -Force
  26Write-Host "[OK] Archive créée : $cheminZip"
  27
  28# --- COPIE AUTOMATIQUE SUR CLÉ USB DÉTECTÉE ---
  29$copieFaite = $false
  30foreach ($lettre in @("D:", "E:", "F:", "G:", "H:")) {
  31    if (Test-Path "$lettre\") {
  32        $info = Get-Volume -DriveLetter $lettre[0] -ErrorAction SilentlyContinue
  33        if ($info -and $info.DriveType -eq "Removable") {
  34            $dossierExterne = Join-Path $lettre "Backups_TFE"
  35            if (-not (Test-Path $dossierExterne)) {
  36                New-Item -ItemType Directory -Path $dossierExterne | Out-Null
  37            }
  38            Copy-Item $cheminZip -Destination $dossierExterne -Force
  39            Write-Host "[OK] Copie USB : $dossierExterne\$NOM_BACKUP.zip"
  40            $copieFaite = $true
  41            break
  42        }
  43    }
  44}
  45if (-not $copieFaite) { Write-Host "[WARN] Aucune clé USB détectée." }
  46
  47# --- NETTOYAGE : garde uniquement les 10 dernières sauvegardes ---
  48$sauvegardes = Get-ChildItem -Path $BACKUP_LOCAL -Filter "*.zip" |
  49               Sort-Object LastWriteTime -Descending
  50if ($sauvegardes.Count -gt $MAX_BACKUPS) {
  51    $sauvegardes | Select-Object -Skip $MAX_BACKUPS | Remove-Item -Force
  52    Write-Host "[INFO] Anciennes sauvegardes supprimées (max $MAX_BACKUPS conservées)"
  53}
  54
  55# --- CRÉATION D'UN README ---
  56$readme = Join-Path $BACKUP_LOCAL "README.txt"
  57"Dernière sauvegarde : $DATE`nFichier : $NOM_BACKUP.zip" | Set-Content $readme
  58Write-Host "[DONE] Sauvegarde terminée : $NOM_BACKUP.zip"
  59

Topologie réseau du projet

L'infrastructure sépare les accès distants (Cloud public) et la gestion physique locale au sein de l'école (INRACI). Tous les flux convergent vers le nœud central : Internet.

☁️ PythonAnywhere (Flask)
🔥 Firebase Realtime DB
📡 Wi-Fi INRACI — 192.168.1.0/24
⚡ ESP32 — 192.168.1.200
🖥️ Admin PC — 192.168.1.20
📱 Utilisateur (Smartphone / PC)
Zone Internet / Cloud Public
📱 Smartphone (HTTPS)
🖥️ Responsable PC (HTTPS)
🐍 Flask — PythonAnywhere
↔ JSON
🔥 Firebase DB (WebSockets)
↕ Liaison Internet (Wi-Fi école)
Zone Réseau Local INRACI — 192.168.1.0/24
⚡ ESP32 — 192.168.1.200 (DHCP Réservé)
polling toutes les 3s →
🔦 Capteur IR TCRT5000
⚡ Module Relais
🔒 Verrou 12V
↕ SSH local (maintenance)
🖥️ Admin PC — 192.168.1.20
→ SSH direct vers ESP32

Description de la topologie

01Acteurs distants (Zone Internet) : L'utilisateur (smartphone) et le responsable (PC/Mac) initient des requêtes HTTPS vers Flask sur PythonAnywhere. Flask communique en JSON avec Firebase Realtime Database pour la persistance et la synchronisation temps réel via WebSockets.
02Infrastructure IoT locale (192.168.1.0/24) : L'ESP32 est connecté au Wi-Fi de l'école avec un bail DHCP statique réservé (192.168.1.200). Il interroge Flask toutes les 3 secondes via polling HTTPS. Dès qu'une commande est détectée, il active le relais pour ouvrir le verrou, et le capteur infrarouge TCRT5000 valide la présence de l'objet.
03Flux de maintenance SSH : L'administrateur (IP : 192.168.1.20) peut établir une session SSH directe vers l'ESP32 depuis le réseau local. Ce flux est strictement interne au LAN — il ne transite jamais par Internet.
04Flux temps réel complet : Réservation → Flask écrit dans Firebase → ESP32 intercepte via polling → relais activé → verrou ouvert → capteur IR confirme le retrait → Firebase mis à jour → stock actualisé sur le site. Toute la chaîne s'exécute en moins de 3 secondes.

Architecture du système

Triangle Flask ↔ Firebase ↔ ESP32. Flask gère la logique métier et le rendu SSR. L'ESP32 interroge Flask (polling HTTPS) toutes les 3s et pilote le hardware. Les données sont conservées en mémoire Python (dictionnaires) pendant la session.

MVC simplifiéServer-Side RenderingPolling HTTPSIn-Memory Python

Flux applicatif complet

🌐 Client
🖥️ Navigateur Web
📱 Mobile
↕ HTTPS
⚙️ Backend — PythonAnywhere
🐍 Flask Routes
🔐 Session Auth
📝 Jinja2 SSR
💰 Amende Engine
📋 Logs
↕ API REST / JSON
☁️ Services externes
🔥 Firebase RT DB
📧 SendGrid
🔒 SHA-256
↕ Polling HTTPS — toutes les 3s
🔌 Hardware — ESP32 (192.168.1.200)
📡 Wi-Fi
🔦 Capteur IR
⚡ Relais
🔒 Verrou 12V
Rapport de fin d'études

TFE 2026

46 pages · Finalisé
Télécharger le PDF
📄

Rapport TFE — Enes Atmac

Mémoire complet de 46 pages — contexte, analyse des besoins, conception, développement et conclusion du projet Loan Track.

Télécharger le PDF

Le PDF s'ouvre dans un nouvel onglet.

🤖
Assistant Loan Track● En ligne
Bonjour 👋 Posez-moi vos questions sur Loan Track — fonctionnement, technique, documentation ou rapport TFE.