Système de casiers connectés pour automatiser la gestion du matériel technique. Réservez en ligne, récupérez en autonomie.
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.
Registres papier sujets aux erreurs, stock impossible à suivre, dépendance totale à la présence d'un responsable pour chaque emprunt ou retour.
Réservation en ligne, code d'accès 6 chiffres envoyé par email, casier ouvert sans contact. Stock mis à jour instantanément via Firebase.
Choisies pour leur fiabilité, leur documentation et leur compatibilité avec un déploiement cloud accessible.
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.
Framework léger pour les routes HTTP, la logique métier, les sessions et le rendu Jinja2. ~1 189 lignes de code applicatif.
Lien central entre Flask et l'ESP32. Stocke les utilisateurs, le stock, les emprunts et l'état des casiers avec synchronisation instantanée.
7 types d'emails automatisés — confirmation emprunt avec code, alertes retard, reset MDP, notifications de message et paiement.
Déploiement Flask stable, accessible 24h/24 à l'adresse loantrack.pythonanywhere.com sans maintenance serveur manuelle.
MDP hashés SHA-256, sessions Flask, décorateurs @login_required et @role_required. Maintenance ESP32 à distance via SSH sur le LAN.
De la réservation au retour — tout le cycle de vie d'un emprunt, automatisé.
18 équipements — MacBook Pro, iPad, Arduino, Sony Alpha 7, microscope… Stock affiché en temps réel via Firebase.
Code 6 chiffres généré et envoyé par email à chaque réservation. Ouvre le casier physique sans contact humain.
1€ dès la 1ʳᵉ minute de retard, +1€/jour. Alertes email automatiques. Paiement par carte intégré dans l'app.
Fil de discussion entre élèves, responsables et admins. Notifications email à chaque nouveau message.
Élève · Responsable · Administrateur. Permissions distinctes gérées par décorateurs Flask.
Chaque action est horodatée — emprunts, retours, paiements, connexions. Traçabilité complète pour l'admin.
Aucune interaction humaine requise. Le système gère tout de façon autonome.
L'élève se connecte avec son adresse INRACI. Le rôle est détecté automatiquement.
Consultation du catalogue, vérification du stock en temps réel, sélection de la durée.
Code unique à 6 chiffres généré et envoyé immédiatement par SendGrid.
Code saisi dans l'app → ESP32 reçoit l'ordre → relais activé → verrou ouvert.
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.
Récupérez l'archive complète du code source de l'application web Flask.
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.
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)
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é.
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>
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.
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}
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Ω.
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}
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.
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
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
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.
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
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.
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.
Mémoire complet de 46 pages — contexte, analyse des besoins, conception, développement et conclusion du projet Loan Track.
Le PDF s'ouvre dans un nouvel onglet.