forgium-website/Idées/emploi_du_temps.html

1403 lines
50 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forgium Calendar | Emploi du Temps 7J / 24H (Q1 & Q2)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700;800&family=JetBrains+Mono:wght@500;700;800&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<style>
:root {
--bg-dark: #0b0f17;
--bg-card: rgba(18, 24, 38, 0.85);
--border-color: rgba(255, 255, 255, 0.08);
--text-main: #f3f4f6;
--text-muted: #94a3b8;
--primary: #6366f1;
--accent-cyan: #06b6d4;
--accent-amber: #f59e0b;
--hour-height: 72px; /* Hauteur confortable évitant tout écrasement */
--sidebar-width: 65px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg-dark);
color: var(--text-main);
min-height: 100vh;
background-image:
radial-gradient(circle at 10% 15%, rgba(99, 102, 241, 0.1) 0%, transparent 40%),
radial-gradient(circle at 90% 85%, rgba(6, 182, 212, 0.08) 0%, transparent 40%);
background-attachment: fixed;
}
/* Barre supérieure */
header {
backdrop-filter: blur(16px);
background: rgba(11, 15, 23, 0.9);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 50;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.brand-section {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-badge {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #6366f1, #06b6d4);
border-radius: 10px;
display: grid;
place-items: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 800;
font-size: 1.1rem;
color: white;
}
.brand-title {
font-size: 1.15rem;
font-weight: 800;
letter-spacing: -0.02em;
color: #fff;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.85rem;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
border: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.04);
color: var(--text-main);
transition: all 0.15s ease;
}
.btn:hover {
background: rgba(255, 255, 255, 0.09);
border-color: rgba(255, 255, 255, 0.2);
}
.btn-primary {
background: linear-gradient(135deg, #4f46e5, #6366f1);
border-color: rgba(99, 102, 241, 0.4);
color: #ffffff;
}
.btn-emerald {
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.3);
color: #34d399;
}
.btn-cyan {
background: rgba(6, 182, 212, 0.15);
border-color: rgba(6, 182, 212, 0.3);
color: #38bdf8;
}
.btn-danger {
background: rgba(244, 63, 94, 0.12);
border-color: rgba(244, 63, 94, 0.3);
color: #fb7185;
}
.calendar-wrapper {
max-width: 1600px;
margin: 1.25rem auto;
padding: 0 1rem 3rem 1rem;
}
/* Barre d'information & Titre personnalisable */
.calendar-info-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.85rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 0.75rem 1.25rem;
flex-wrap: wrap;
gap: 0.75rem;
}
.schedule-title-input {
background: transparent;
border: none;
font-size: 1.15rem;
font-weight: 700;
color: var(--text-main);
outline: none;
border-bottom: 1px dashed transparent;
padding: 0.2rem 0.4rem;
border-radius: 4px;
min-width: 280px;
}
.schedule-title-input:hover, .schedule-title-input:focus {
background: rgba(255, 255, 255, 0.05);
border-bottom-color: var(--accent-cyan);
}
.badge-indicator {
font-size: 0.8rem;
padding: 0.3rem 0.75rem;
border-radius: 20px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.4rem;
}
.badge-indicator strong {
color: var(--text-main);
}
/* Grille principale */
.calendar-container {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
}
/* En-tête des 7 jours */
.days-header {
display: grid;
grid-template-columns: var(--sidebar-width) repeat(7, 1fr);
background: rgba(15, 23, 42, 0.95);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 57px;
z-index: 20;
}
.day-col-header {
padding: 0.85rem 0.5rem;
text-align: center;
border-right: 1px solid var(--border-color);
}
.day-col-header:last-child {
border-right: none;
}
.day-col-header .day-name {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #e2e8f0;
}
.day-col-header.weekend .day-name {
color: var(--accent-amber);
}
.timezone-label {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
border-right: 1px solid var(--border-color);
background: rgba(0, 0, 0, 0.2);
}
/* Grille 24h scrollable */
.calendar-grid-scroll {
position: relative;
overflow-y: auto;
max-height: calc(100vh - 185px);
display: grid;
grid-template-columns: var(--sidebar-width) repeat(7, 1fr);
}
.time-sidebar {
background: rgba(11, 15, 23, 0.85);
border-right: 1px solid var(--border-color);
user-select: none;
}
.hour-label {
height: var(--hour-height);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 4px;
font-size: 0.72rem;
font-family: 'JetBrains Mono', monospace;
color: var(--text-muted);
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.day-column {
position: relative;
border-right: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.01);
}
.day-column:last-child {
border-right: none;
}
.day-column.weekend {
background: rgba(255, 255, 255, 0.025);
}
.hour-cell {
height: var(--hour-height);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
cursor: pointer;
position: relative;
transition: background 0.15s ease;
}
.hour-cell:hover {
background: rgba(99, 102, 241, 0.08);
}
/* Compression des heures de nuit (00:00 à 05:00) : les 5 heures tiennent ensemble dans l'espace d'1 heure (~72px au total) */
.hour-label.compact-night {
height: 14.4px;
font-size: 0.62rem;
padding-top: 0;
align-items: center;
opacity: 0.7;
background: rgba(0, 0, 0, 0.35);
}
.hour-cell.compact-night {
height: 14.4px;
background: rgba(0, 0, 0, 0.15);
border-bottom: 1px dashed rgba(255, 255, 255, 0.03);
}
/* =========================================================================
BLOCS DE COURS / ÉVÉNEMENTS (LISIBILITÉ MAXIMALE SANS ÉCRASEMENT)
========================================================================= */
.event-block {
position: absolute;
border-radius: 6px;
padding: 5px 6px;
color: #0f172a;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.35);
z-index: 10;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow: hidden;
border: 1.5px solid rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease, box-shadow 0.15s ease;
box-sizing: border-box;
}
.event-block:hover {
transform: translateY(-2px) scale(1.02);
z-index: 20;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.55);
}
.event-top-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 3px;
line-height: 1;
margin-bottom: 2px;
}
.event-time-tag {
font-size: 0.62rem;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: #1e293b;
white-space: nowrap;
}
.event-q-badge {
font-size: 0.58rem;
font-family: 'JetBrains Mono', monospace;
padding: 1px 4px;
border-radius: 3px;
font-weight: 800;
line-height: 1;
border: 1px solid rgba(0,0,0,0.35);
}
.badge-q1 { background: #bfdbfe; color: #1e40af; }
.badge-q2 { background: #fde68a; color: #92400e; }
.event-title {
font-weight: 800;
font-size: 0.72rem;
line-height: 1.15;
color: #000000;
text-transform: uppercase;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.event-meta {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: auto;
font-size: 0.63rem;
font-family: 'JetBrains Mono', monospace;
line-height: 1.1;
color: #1e293b;
overflow: hidden;
}
.event-prof {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
}
.event-salle {
align-self: flex-start;
background: rgba(0, 0, 0, 0.12);
padding: 0 4px;
border-radius: 3px;
color: #000000;
font-weight: 800;
font-size: 0.62rem;
white-space: nowrap;
}
/* Thèmes couleurs */
.bg-bleu-clair { background: #89cff0; border-color: #0284c7; }
.bg-peche-beige { background: #f6c2a1; border-color: #ea580c; }
.bg-bleu-gris { background: #b0c4de; border-color: #475569; }
.bg-vert-eau { background: #a7f3d0; border-color: #059669; }
.bg-gris-tres-clair { background: #e2e8f0; border-color: #64748b; }
.bg-blanc { background: #ffffff; border-color: #94a3b8; }
/* Modal Formulaire */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(5, 8, 15, 0.75);
backdrop-filter: blur(8px);
z-index: 100;
display: none;
place-items: center;
padding: 1rem;
}
.modal-overlay.active { display: grid; }
.modal-card {
background: #111827;
border: 1px solid var(--border-color);
border-radius: 16px;
width: 100%;
max-width: 480px;
padding: 1.35rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-title { font-size: 1.1rem; font-weight: 700; color: #fff; }
.close-btn { background: transparent; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.25rem; }
.form-group { margin-bottom: 0.85rem; }
.form-label { display: block; font-size: 0.78rem; font-weight: 600; color: var(--text-muted); margin-bottom: 0.3rem; }
.form-input, .form-select {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.6rem 0.8rem;
color: var(--text-main);
font-family: inherit;
font-size: 0.88rem;
outline: none;
}
.form-input:focus, .form-select:focus { border-color: var(--primary); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; }
.color-picker-group { display: flex; gap: 0.45rem; margin-top: 0.3rem; }
.color-dot {
width: 26px;
height: 26px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
}
.color-dot.selected { border-color: #fff; box-shadow: 0 0 8px rgba(255,255,255,0.4); }
.modal-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.25rem;
padding-top: 0.85rem;
border-top: 1px solid var(--border-color);
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: #1e293b;
color: white;
border: 1px solid var(--border-color);
padding: 0.7rem 1.15rem;
border-radius: 9px;
z-index: 200;
font-size: 0.85rem;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s ease;
}
.toast.show { transform: translateY(0); opacity: 1; }
/* =========================================================================
STYLE D'IMPRESSION ET D'EXPORT PDF (1 SEULE PAGE A4 PAYSAGE PARFAITE)
========================================================================= */
@page {
size: A4 landscape;
margin: 4mm 5mm;
}
@media print {
html, body {
background: #ffffff !important;
color: #000000 !important;
background-image: none !important;
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
height: auto !important;
overflow: visible !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
header, .toolbar-actions, .modal-overlay, .toast {
display: none !important;
}
.calendar-wrapper {
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
page-break-inside: avoid !important;
break-inside: avoid !important;
}
.calendar-info-bar {
border: none !important;
background: transparent !important;
padding: 0 0 2px 0 !important;
margin-bottom: 2px !important;
}
.schedule-title-input {
color: #000000 !important;
font-size: 1.1rem !important;
font-weight: 800 !important;
border: none !important;
}
.badge-indicator {
border-color: #cbd5e1 !important;
background: #f8fafc !important;
color: #475569 !important;
padding: 1px 6px !important;
font-size: 0.72rem !important;
}
.badge-indicator strong {
color: #0f172a !important;
}
.calendar-container {
border: 1.5px solid #0f172a !important;
box-shadow: none !important;
border-radius: 4px !important;
page-break-inside: avoid !important;
break-inside: avoid !important;
}
.days-header {
position: static !important;
background: #f1f5f9 !important;
border-bottom: 1.5px solid #0f172a !important;
}
.day-col-header {
padding: 2px 2px !important;
border-right: 1px solid #cbd5e1 !important;
}
.day-col-header .day-name {
color: #0f172a !important;
font-size: 0.72rem !important;
font-weight: 800 !important;
}
.calendar-grid-scroll {
max-height: none !important;
overflow: visible !important;
height: auto !important;
}
.time-sidebar {
background: #f8fafc !important;
border-right: 1.5px solid #0f172a !important;
}
.hour-label {
color: #334155 !important;
font-weight: 700 !important;
font-size: 0.6rem !important;
border-bottom: 1px solid #e2e8f0 !important;
}
.day-column {
border-right: 1px solid #cbd5e1 !important;
background: #ffffff !important;
}
.hour-cell {
border-bottom: 1px solid #f1f5f9 !important;
}
.hour-label.compact-night {
height: 5px !important;
font-size: 0.48rem !important;
background: #f1f5f9 !important;
}
.hour-cell.compact-night {
height: 5px !important;
background: #f8fafc !important;
}
.event-block {
box-shadow: none !important;
border-radius: 3px !important;
page-break-inside: avoid !important;
break-inside: avoid !important;
}
}
</style>
</head>
<body>
<!-- Barre Supérieure -->
<header>
<div class="brand-section">
<div class="logo-badge">FG</div>
<div>
<div class="brand-title">FORGIUM CALENDAR</div>
</div>
</div>
<div class="toolbar-actions">
<!-- Nouvel événement -->
<button class="btn btn-primary" onclick="openCreateModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
Nouveau créneau
</button>
<!-- Charger modèle de base -->
<button class="btn" onclick="resetDefaultTemplate()" title="Recharger le planning type avec Q1/Q2">
Recharger Modèle
</button>
<!-- Format FG Calendar -->
<button class="btn btn-cyan" onclick="exportFgCalendar()" title="Sauvegarder en fichier .fgcalendar">
Exporter .fgcalendar
</button>
<label class="btn btn-cyan" style="cursor:pointer;" title="Charger un fichier .fgcalendar">
Importer .fgcalendar
<input type="file" id="file-import" accept=".fgcalendar,.json" style="display:none;" onchange="importFgCalendar(event)">
</label>
<!-- Export PDF Téléchargement direct -->
<button class="btn btn-emerald" onclick="exportToPDF()" title="Télécharger directement l'emploi du temps en fichier PDF">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Export PDF
</button>
<!-- Imprimer / Enregistrer en PDF natif vectoriel -->
<button class="btn" onclick="window.print()" title="Imprimer ou enregistrer en PDF HD vectoriel (1 page)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>
Imprimer
</button>
<!-- Vider la grille -->
<button class="btn btn-danger" onclick="clearAllEvents()" title="Effacer tous les cours">
Vider
</button>
</div>
</header>
<!-- Contenu Principal -->
<main class="calendar-wrapper" id="calendar-export-area">
<div class="calendar-info-bar">
<div style="display:flex; align-items:center; gap:0.6rem;">
<span style="font-size:1.1rem;">📅</span>
<input type="text" id="schedule-title" class="schedule-title-input" value="Emploi du Temps Hebdomadaire" oninput="saveScheduleTitle()">
</div>
<div style="display:flex; align-items:center; gap:0.6rem;">
<div class="badge-indicator">
<span>Format :</span>
<strong style="color:var(--accent-cyan);">7 Jours / 24 Heures</strong>
</div>
<div class="badge-indicator">
<span>Créneaux programmés :</span>
<strong id="events-count">0</strong>
</div>
</div>
</div>
<div class="calendar-container">
<!-- En-tête des 7 jours (Lundi à Dimanche) -->
<div class="days-header">
<div class="timezone-label">HEURE</div>
<div class="day-col-header" data-day="0"><div class="day-name">Lundi</div></div>
<div class="day-col-header" data-day="1"><div class="day-name">Mardi</div></div>
<div class="day-col-header" data-day="2"><div class="day-name">Mercredi</div></div>
<div class="day-col-header" data-day="3"><div class="day-name">Jeudi</div></div>
<div class="day-col-header" data-day="4"><div class="day-name">Vendredi</div></div>
<div class="day-col-header weekend" data-day="5"><div class="day-name">Samedi</div></div>
<div class="day-col-header weekend" data-day="6"><div class="day-name">Dimanche</div></div>
</div>
<!-- Grille 24 heures complète (00:00 à 23:00) -->
<div class="calendar-grid-scroll" id="grid-scroll">
<div class="time-sidebar" id="time-sidebar"></div>
<div class="day-column" id="day-col-0" data-day="0"></div>
<div class="day-column" id="day-col-1" data-day="1"></div>
<div class="day-column" id="day-col-2" data-day="2"></div>
<div class="day-column" id="day-col-3" data-day="3"></div>
<div class="day-column" id="day-col-4" data-day="4"></div>
<div class="day-column weekend" id="day-col-5" data-day="5"></div>
<div class="day-column weekend" id="day-col-6" data-day="6"></div>
</div>
</div>
</main>
<!-- Modal Création / Modification d'événement -->
<div class="modal-overlay" id="event-modal">
<div class="modal-card">
<div class="modal-header">
<div class="modal-title" id="modal-title">Nouveau créneau</div>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<form id="event-form" onsubmit="handleFormSubmit(event)">
<input type="hidden" id="event-id">
<div class="form-group">
<label class="form-label" for="event-title-input">Matière / Titre *</label>
<input type="text" id="event-title-input" class="form-input" placeholder="Ex: INGENIERIE & DEVLPT DURABLE, MATHEMATIQUES..." required autofocus>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="event-prof-input">Professeur(s) / Intervenant</label>
<input type="text" id="event-prof-input" class="form-input" placeholder="Ex: BOUTBHIRT Y., LARRAZET P.">
</div>
<div class="form-group">
<label class="form-label" for="event-salle-input">Salle / Lieu</label>
<input type="text" id="event-salle-input" class="form-input" placeholder="Ex: T209, B12, J20...">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="event-day-select">Jour *</label>
<select id="event-day-select" class="form-select">
<option value="0">Lundi</option>
<option value="1">Mardi</option>
<option value="2">Mercredi</option>
<option value="3">Jeudi</option>
<option value="4">Vendredi</option>
<option value="5">Samedi</option>
<option value="6">Dimanche</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="event-qmode-select">Alternance Quinzaine *</label>
<select id="event-qmode-select" class="form-select">
<option value="">Commun (Toutes les semaines Q1 & Q2)</option>
<option value="Q1">Semaine Q1 uniquement (Impaire)</option>
<option value="Q2">Semaine Q2 uniquement (Paire)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="event-start-time">Heure de début *</label>
<input type="time" id="event-start-time" class="form-input" required onchange="handleStartTimeChange()">
</div>
<div class="form-group">
<label class="form-label" for="event-end-time">Heure de fin *</label>
<input type="time" id="event-end-time" class="form-input" required>
</div>
</div>
<div class="form-group">
<label class="form-label">Couleur</label>
<div class="color-picker-group">
<div class="color-dot bg-bleu-clair selected" data-color="bg-bleu-clair" onclick="selectColor('bg-bleu-clair')"></div>
<div class="color-dot bg-peche-beige" data-color="bg-peche-beige" onclick="selectColor('bg-peche-beige')"></div>
<div class="color-dot bg-bleu-gris" data-color="bg-bleu-gris" onclick="selectColor('bg-bleu-gris')"></div>
<div class="color-dot bg-vert-eau" data-color="bg-vert-eau" onclick="selectColor('bg-vert-eau')"></div>
<div class="color-dot bg-gris-tres-clair" data-color="bg-gris-tres-clair" onclick="selectColor('bg-gris-tres-clair')"></div>
<div class="color-dot bg-blanc" data-color="bg-blanc" onclick="selectColor('bg-blanc')"></div>
</div>
<input type="hidden" id="event-color" value="bg-bleu-clair">
</div>
<div class="modal-actions">
<button type="button" class="btn" id="btn-delete-event" onclick="deleteCurrentEvent()" style="display:none; color:#fb7185; border-color:rgba(244,63,94,0.3);">
Supprimer
</button>
<div style="display:flex; gap:0.5rem; margin-left:auto;">
<button type="button" class="btn" onclick="closeModal()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer</button>
</div>
</div>
</form>
</div>
</div>
<div class="toast" id="toast-message">Action effectuée</div>
<!-- Logique JavaScript -->
<script>
const STORAGE_KEY = 'forgium_calendar_7j_24h_v2';
const HOUR_HEIGHT = 72; // 72px par heure normale
const NIGHT_HOURS_COUNT = 5; // 00h00 à 05h00
const NIGHT_TOTAL_HEIGHT = HOUR_HEIGHT; // Les 5 heures de nuit tiennent dans 72px (14.4px par heure)
const NIGHT_HOUR_HEIGHT = NIGHT_TOTAL_HEIGHT / NIGHT_HOURS_COUNT; // 14.4px par heure de nuit
let state = {
title: "Emploi du Temps Hebdomadaire",
events: []
};
// Modèle de base avec les cours réels, samedi et dimanche prêts, et cohabitation Q1/Q2
function getDefaultSchedule() {
return [
// LUNDI
{ id: "c1", day: 0, startTime: "07:55", endTime: "08:55", title: "HISTOIRE-GEOGRAPHIE", prof: "ELICHONDOBORDE V.", salle: "J13", qMode: "", color: "bg-blanc" },
{ id: "c2", day: 0, startTime: "08:55", endTime: "09:55", title: "HISTOIRE-GEOGRAPHIE", prof: "ELICHONDOBORDE V.", salle: "J13", qMode: "", color: "bg-blanc" },
// Lundi 10h05 - 11h05 : FRANCAIS (Q2)
{ id: "c3", day: 0, startTime: "10:05", endTime: "11:05", title: "FRANCAIS", prof: "ROUCHON C.", salle: "J20", qMode: "Q2", color: "bg-peche-beige" },
// Lundi 11h05 - 11h55 : Scission Q1 / Q2 (Français vs Anglais)
{ id: "c4", day: 0, startTime: "11:05", endTime: "11:55", title: "FRANCAIS", prof: "ROUCHON C.", salle: "J20", qMode: "Q1", color: "bg-peche-beige" },
{ id: "c5", day: 0, startTime: "11:05", endTime: "11:55", title: "ANGLAIS LVA", prof: "LASSERRE V.", salle: "B11", qMode: "Q2", color: "bg-blanc" },
// Lundi après-midi
{ id: "c6", day: 0, startTime: "15:00", endTime: "15:55", title: "ENS. TECHNO. EN LV1", prof: "BOUTBHIRT Y., LASSERRE V.", salle: "T201", qMode: "", color: "bg-peche-beige" },
{ id: "c7", day: 0, startTime: "16:00", endTime: "18:00", title: "PHYSIQ.CHIMIE&MATHS", prof: "DESTRIBOS N.", salle: "<1H-STI2D> B23 tp", qMode: "", color: "bg-blanc" },
// MARDI
{ id: "c8", day: 1, startTime: "07:55", endTime: "08:55", title: "INGENIERIE & DEVLPT DURABLE", prof: "BOUTBHIRT Y.", salle: "T209", qMode: "", color: "bg-bleu-clair" },
{ id: "c9", day: 1, startTime: "10:05", endTime: "11:55", title: "INGENIERIE & DEVLPT DURABLE", prof: "BOUTBHIRT Y., LARRAZET P.", salle: "T209", qMode: "", color: "bg-bleu-clair" },
{ id: "c10", day: 1, startTime: "11:55", endTime: "12:55", title: "INGENIERIE & DEVLPT DURABLE", prof: "LARRAZET P.", salle: "T209", qMode: "", color: "bg-bleu-clair" },
{ id: "c11", day: 1, startTime: "14:00", endTime: "15:00", title: "ANGLAIS LVA", prof: "LASSERRE V.", salle: "B11", qMode: "", color: "bg-blanc" },
{ id: "c12", day: 1, startTime: "15:00", endTime: "16:00", title: "MATHEMATIQUES", prof: "CHARLAT C.", salle: "B13", qMode: "", color: "bg-bleu-gris" },
// MERCREDI
{ id: "c13", day: 2, startTime: "08:55", endTime: "09:55", title: "PHYSIQ.CHIMIE&MATHS", prof: "DESTRIBOS N.", salle: "B22", qMode: "", color: "bg-gris-tres-clair" },
{ id: "c14", day: 2, startTime: "10:05", endTime: "11:55", title: "ED.PHYSIQUE & SPORT.", prof: "COUDRAY M., DUPIN L., PAULIEN G.", salle: "Gymnase", qMode: "", color: "bg-blanc" },
// JEUDI
{ id: "c15", day: 3, startTime: "07:55", endTime: "08:55", title: "INGENIERIE & DEVLPT DURABLE", prof: "LARRAZET P.", salle: "T209", qMode: "", color: "bg-bleu-clair" },
{ id: "c16", day: 3, startTime: "10:05", endTime: "11:55", title: "ING. & DEVLPT DURA.", prof: "BOUTBHIRT Y., LARRAZET P.", salle: "T209", qMode: "", color: "bg-bleu-clair" },
{ id: "c17", day: 3, startTime: "11:55", endTime: "12:55", title: "PHYSIQ.CHIMIE&MATHS", prof: "CHARLAT C.", salle: "B12", qMode: "", color: "bg-gris-tres-clair" },
{ id: "c18", day: 3, startTime: "14:00", endTime: "15:00", title: "MATHEMATIQUES", prof: "CHARLAT C.", salle: "B12", qMode: "", color: "bg-bleu-gris" },
{ id: "c19", day: 3, startTime: "16:00", endTime: "18:00", title: "ING. & DEVLPT DURA.", prof: "BOUTBHIRT Y., LAFARGUE E.", salle: "T105 FABLAB / T111", qMode: "", color: "bg-bleu-clair" },
// VENDREDI
{ id: "c20", day: 4, startTime: "08:55", endTime: "09:55", title: "FRANCAIS", prof: "ROUCHON C.", salle: "J20", qMode: "", color: "bg-peche-beige" },
{ id: "c21", day: 4, startTime: "10:05", endTime: "11:05", title: "ESP2 - ESPAGNOL LV2", prof: "DAUDIGNON I.", salle: "B15", qMode: "", color: "bg-blanc" },
{ id: "c22", day: 4, startTime: "14:00", endTime: "15:00", title: "MATHEMATIQUES", prof: "CHARLAT C.", salle: "B12", qMode: "", color: "bg-vert-eau" },
{ id: "c23", day: 4, startTime: "15:00", endTime: "16:00", title: "PHYSIQ.CHIMIE&MATHS", prof: "CHARLAT C.", salle: "B12", qMode: "", color: "bg-gris-tres-clair" }
];
}
window.addEventListener('DOMContentLoaded', () => {
loadState();
buildGrid();
renderEvents();
scrollToMorning();
});
function showToast(msg) {
const toast = document.getElementById('toast-message');
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
}
function loadState() {
// Nettoyage / migration transparente pour écraser les anciennes données trop courtes
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
state.title = parsed.title || state.title;
state.events = parsed.events || [];
document.getElementById('schedule-title').value = state.title;
} catch(e) {
state.events = getDefaultSchedule();
saveState();
}
} else {
state.events = getDefaultSchedule();
saveState();
}
}
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
document.getElementById('events-count').textContent = state.events.length;
}
function saveScheduleTitle() {
state.title = document.getElementById('schedule-title').value;
saveState();
}
function resetDefaultTemplate() {
if (confirm("Recharger le modèle d'emploi du temps par défaut ?")) {
state.title = "Emploi du Temps Hebdomadaire";
document.getElementById('schedule-title').value = state.title;
state.events = getDefaultSchedule();
saveState();
renderEvents();
showToast("Modèle rechargé avec succès !");
}
}
function clearAllEvents() {
if (confirm("Voulez-vous effacer TOUS les créneaux ?")) {
state.events = [];
saveState();
renderEvents();
showToast("Planning réinitialisé à vide.");
}
}
// Construction de la grille 24 heures (00:00 à 23:00) sur 7 jours
// Les heures de nuit 00h à 05h sont compactées pour n'occuper que l'espace d'1 heure
function buildGrid() {
const sidebar = document.getElementById('time-sidebar');
sidebar.innerHTML = '';
for (let h = 0; h < 24; h++) {
const hourStr = String(h).padStart(2, '0') + ':00';
const label = document.createElement('div');
label.className = 'hour-label' + (h < 5 ? ' compact-night' : '');
label.textContent = hourStr;
sidebar.appendChild(label);
}
for (let day = 0; day < 7; day++) {
const col = document.getElementById(`day-col-${day}`);
col.innerHTML = '';
for (let h = 0; h < 24; h++) {
const cell = document.createElement('div');
cell.className = 'hour-cell' + (h < 5 ? ' compact-night' : '');
cell.dataset.hour = h;
cell.dataset.day = day;
cell.title = `Cliquer pour ajouter un créneau à ${String(h).padStart(2, '0')}:00`;
cell.addEventListener('click', (e) => {
if (e.target.closest('.event-block')) return;
openCreateModal(day, h);
});
col.appendChild(cell);
}
}
}
function timeToMinutes(timeStr) {
const [h, m] = timeStr.split(':').map(Number);
return h * 60 + m;
}
// Convertit les minutes (0 à 1440) en position verticale exacte en pixels
// De 00:00 à 05:00 (0 à 300 min), l'ensemble fait 72px (NIGHT_TOTAL_HEIGHT)
// De 05:00 à 24:00 (300 à 1440 min), chaque heure fait 72px (HOUR_HEIGHT)
function minuteToY(min) {
const NIGHT_MINUTES = 5 * 60; // 300 min
if (min <= NIGHT_MINUTES) {
return (min / NIGHT_MINUTES) * NIGHT_TOTAL_HEIGHT;
} else {
return NIGHT_TOTAL_HEIGHT + ((min - NIGHT_MINUTES) / 60) * HOUR_HEIGHT;
}
}
// Affichage des créneaux aéré et net
function renderEvents() {
document.querySelectorAll('.event-block').forEach(el => el.remove());
const dayEvents = {};
for (let d = 0; d < 7; d++) dayEvents[d] = [];
state.events.forEach(ev => {
if (dayEvents[ev.day]) dayEvents[ev.day].push(ev);
});
for (let day = 0; day < 7; day++) {
const col = document.getElementById(`day-col-${day}`);
if (!col) continue;
const events = dayEvents[day];
events.forEach(event => {
const startMin = timeToMinutes(event.startTime);
let endMin = timeToMinutes(event.endTime);
if (endMin <= startMin) endMin = startMin + 55;
const topPx = minuteToY(startMin);
const bottomPx = minuteToY(endMin);
// Hauteur proportionnelle à la zone, minimum 22px si très court
const heightPx = Math.max(bottomPx - topPx - 3, 22);
// Détecter chevauchement Q1 / Q2
const overlapping = events.filter(other => {
if (other.id === event.id) return false;
const oStart = timeToMinutes(other.startTime);
const oEnd = timeToMinutes(other.endTime);
return (startMin < oEnd && endMin > oStart);
});
const card = document.createElement('div');
card.className = `event-block ${event.color || 'bg-bleu-clair'}`;
card.style.top = `${topPx}px`;
card.style.height = `${heightPx}px`;
if (overlapping.length > 0 || event.qMode === 'Q2' || event.qMode === 'Q1') {
if (event.qMode === 'Q1') {
card.style.left = '2px';
card.style.width = 'calc(50% - 3px)';
} else if (event.qMode === 'Q2') {
card.style.left = 'calc(50% + 1px)';
card.style.width = 'calc(50% - 3px)';
} else {
card.style.left = '2px';
card.style.width = 'calc(100% - 4px)';
}
} else {
card.style.left = '2px';
card.style.width = 'calc(100% - 4px)';
}
let qBadge = '';
if (event.qMode === 'Q1') {
qBadge = `<span class="event-q-badge badge-q1">Q1</span>`;
} else if (event.qMode === 'Q2') {
qBadge = `<span class="event-q-badge badge-q2">Q2</span>`;
}
// Métadonnées (prof & salle)
let metaHtml = '';
if (event.prof || event.salle) {
metaHtml = `
<div class="event-meta">
${event.prof ? `<div class="event-prof" title="${escapeHtml(event.prof)}">${escapeHtml(event.prof)}</div>` : ''}
${event.salle ? `<div class="event-salle">${escapeHtml(event.salle)}</div>` : ''}
</div>
`;
}
card.innerHTML = `
<div class="event-top-meta">
<span class="event-time-tag">${event.startTime} - ${event.endTime}</span>
${qBadge}
</div>
<div class="event-title" title="${escapeHtml(event.title)}">${escapeHtml(event.title)}</div>
${heightPx >= 46 ? metaHtml : ''}
`;
card.addEventListener('click', (e) => {
e.stopPropagation();
openEditModal(event.id);
});
col.appendChild(card);
});
}
document.getElementById('events-count').textContent = state.events.length;
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function scrollToMorning() {
const scrollEl = document.getElementById('grid-scroll');
// Positionnement fluide pour voir immédiatement l'ensemble de la matinée dès 07h00
scrollEl.scrollTop = 0;
}
// Modal Création / Edition
function openCreateModal(defaultDay = 0, defaultHour = 8) {
document.getElementById('modal-title').textContent = "Nouveau créneau";
document.getElementById('event-id').value = '';
document.getElementById('event-title-input').value = '';
document.getElementById('event-prof-input').value = '';
document.getElementById('event-salle-input').value = '';
document.getElementById('event-day-select').value = String(defaultDay);
document.getElementById('event-qmode-select').value = '';
const startH = String(defaultHour).padStart(2, '0');
let endH = String(defaultHour + 1).padStart(2, '0');
let endM = "00";
if (defaultHour >= 23) {
endH = "23";
endM = "59";
}
document.getElementById('event-start-time').value = `${startH}:00`;
document.getElementById('event-end-time').value = `${endH}:${endM}`;
document.getElementById('btn-delete-event').style.display = 'none';
selectColor('bg-bleu-clair');
document.getElementById('event-modal').classList.add('active');
document.getElementById('event-title-input').focus();
}
// Calcul automatique de l'heure de fin conseillée (+1h) quand l'heure de début est modifiée
function handleStartTimeChange() {
const startVal = document.getElementById('event-start-time').value;
if (!startVal) return;
const [h, m] = startVal.split(':').map(Number);
let endMin = h * 60 + m + 60; // +1 heure par défaut
if (endMin >= 1440) endMin = 1439; // Max 23:59
const endH = String(Math.floor(endMin / 60)).padStart(2, '0');
const endM = String(endMin % 60).padStart(2, '0');
document.getElementById('event-end-time').value = `${endH}:${endM}`;
}
function openEditModal(id) {
const event = state.events.find(e => e.id === id);
if (!event) return;
document.getElementById('modal-title').textContent = "Modifier le cours";
document.getElementById('event-id').value = event.id;
document.getElementById('event-title-input').value = event.title;
document.getElementById('event-prof-input').value = event.prof || '';
document.getElementById('event-salle-input').value = event.salle || '';
document.getElementById('event-day-select').value = String(event.day);
document.getElementById('event-qmode-select').value = event.qMode || '';
document.getElementById('event-start-time').value = event.startTime;
document.getElementById('event-end-time').value = event.endTime;
selectColor(event.color || 'bg-bleu-clair');
document.getElementById('btn-delete-event').style.display = 'block';
document.getElementById('event-modal').classList.add('active');
}
function closeModal() {
document.getElementById('event-modal').classList.remove('active');
}
function selectColor(colorClass) {
document.getElementById('event-color').value = colorClass;
document.querySelectorAll('.color-dot').forEach(dot => {
dot.classList.toggle('selected', dot.dataset.color === colorClass);
});
}
function handleFormSubmit(e) {
e.preventDefault();
const id = document.getElementById('event-id').value;
const title = document.getElementById('event-title-input').value.trim();
const prof = document.getElementById('event-prof-input').value.trim();
const salle = document.getElementById('event-salle-input').value.trim();
const day = parseInt(document.getElementById('event-day-select').value, 10);
const qMode = document.getElementById('event-qmode-select').value;
const startTime = document.getElementById('event-start-time').value;
let endTime = document.getElementById('event-end-time').value;
const color = document.getElementById('event-color').value;
if (!title || !startTime || !endTime) return;
let startMin = timeToMinutes(startTime);
let endMin = timeToMinutes(endTime);
// Si l'utilisateur saisit 00:00 comme fin pour un créneau de nuit qui termine la journée, le traiter comme fin de journée (24h00 = 1440 min)
if (endTime === "00:00" && startMin > 0) {
endMin = 1440;
endTime = "23:59";
}
if (endMin <= startMin) {
alert(`Attention : l'heure de fin (${endTime}) doit être après l'heure de début (${startTime}) !\nPar exemple : ${startTime} à ${String(Math.min(parseInt(startTime.split(':')[0])+1, 23)).padStart(2, '0')}:${startTime.split(':')[1] || '00'}`);
document.getElementById('event-end-time').focus();
return;
}
if (id) {
const index = state.events.findIndex(e => e.id === id);
if (index !== -1) {
state.events[index] = { id, title, prof, salle, day, qMode, startTime, endTime, color };
showToast("Créneau mis à jour !");
}
} else {
const newEvent = {
id: 'c_' + Math.random().toString(36).substr(2, 9),
title, prof, salle, day, qMode, startTime, endTime, color
};
state.events.push(newEvent);
showToast("Créneau ajouté !");
}
saveState();
renderEvents();
closeModal();
}
function deleteCurrentEvent() {
const id = document.getElementById('event-id').value;
if (!id) return;
if (confirm("Supprimer ce créneau ?")) {
state.events = state.events.filter(e => e.id !== id);
saveState();
renderEvents();
closeModal();
showToast("Créneau supprimé.");
}
}
// Export .fgcalendar
function exportFgCalendar() {
const data = {
format: "FORGIUM_CALENDAR_7J_24H",
version: "5.0",
title: state.title,
exportDate: new Date().toISOString(),
events: state.events
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${state.title.replace(/[^a-zA-Z0-9]/g, '_')}.fgcalendar`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showToast("Fichier .fgcalendar exporté avec succès !");
}
// Rétrocompatibilité universelle de l'import
function importFgCalendar(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(evt) {
try {
const content = JSON.parse(evt.target.result);
state.title = content.title || content.student || file.name.replace(/\.(fgcalendar|json)$/i, '') || "Emploi du Temps Hebdomadaire";
const SLOT_TIMES = [
{ start: "07:55", end: "08:55" },
{ start: "08:55", end: "09:55" },
{ start: "09:55", end: "10:05" },
{ start: "10:05", end: "11:05" },
{ start: "11:05", end: "11:55" },
{ start: "11:55", end: "12:55" },
{ start: "12:55", end: "13:55" },
{ start: "13:55", end: "14:00" },
{ start: "14:00", end: "15:00" },
{ start: "15:00", end: "15:55" },
{ start: "15:55", end: "16:00" },
{ start: "16:00", end: "17:00" },
{ start: "17:00", end: "18:00" }
];
let rawItems = [];
if (Array.isArray(content.events)) {
rawItems = content.events;
} else if (Array.isArray(content.courses)) {
rawItems = content.courses;
} else if (Array.isArray(content)) {
rawItems = content;
} else {
throw new Error("Aucune liste de créneaux reconnue dans ce fichier.");
}
state.events = rawItems.map((item, index) => {
let startTime = item.startTime;
let endTime = item.endTime;
if (!startTime && typeof item.slotStart === 'number') {
const sSlot = Math.min(Math.max(0, item.slotStart), SLOT_TIMES.length - 1);
const eSlot = Math.min(Math.max(0, item.slotStart + (item.slotCount || 1) - 1), SLOT_TIMES.length - 1);
startTime = SLOT_TIMES[sSlot].start;
endTime = SLOT_TIMES[eSlot].end;
}
let qMode = item.qMode || item.quinzaine || '';
if (!qMode) {
if (item.recurrence === 'Q1') qMode = 'Q1';
else if (item.recurrence === 'Q2') qMode = 'Q2';
else if (item.week === 1) qMode = 'Q1';
else if (item.week === 2) qMode = 'Q2';
}
let color = item.color || 'bg-bleu-clair';
if (color.startsWith('color-')) {
const colorMap = {
'color-cyan': 'bg-bleu-clair',
'color-indigo': 'bg-bleu-gris',
'color-emerald': 'bg-vert-eau',
'color-amber': 'bg-peche-beige',
'color-rose': 'bg-peche-beige',
'color-purple': 'bg-gris-tres-clair',
'color-slate': 'bg-blanc'
};
color = colorMap[color] || 'bg-bleu-clair';
}
return {
id: item.id || 'c_' + Date.now() + '_' + index,
day: typeof item.day === 'number' ? item.day : 0,
startTime: startTime || "08:00",
endTime: endTime || "09:00",
title: item.title || (item.isCantine ? "PAUSE REPAS" : "Cours"),
prof: item.prof || '',
salle: item.salle || '',
qMode: qMode,
color: color
};
});
document.getElementById('schedule-title').value = state.title;
saveState();
renderEvents();
showToast(`Succès : ${state.events.length} cours chargés (compatible) !`);
} catch(err) {
alert("Erreur importation : " + err.message);
}
};
reader.readAsText(file);
e.target.value = '';
}
// Export PDF fiable sur 1 page A4 Paysage
function exportToPDF() {
showToast("Préparation de l'export PDF...");
const filename = `${state.title.replace(/[^a-zA-Z0-9]/g, '_')}.pdf`;
const exportArea = document.getElementById('calendar-export-area');
const scrollEl = document.getElementById('grid-scroll');
// 1. Sauvegarder les styles d'ascenseur
const prevMaxHeight = scrollEl.style.maxHeight;
const prevOverflow = scrollEl.style.overflow;
const prevScrollTop = scrollEl.scrollTop;
// Déplier temporairement la grille pour capturer l'ensemble des créneaux
scrollEl.style.maxHeight = 'none';
scrollEl.style.overflow = 'visible';
const opt = {
margin: [4, 4, 4, 4],
filename: filename,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: {
scale: 2,
useCORS: true,
logging: false,
scrollY: 0,
scrollX: 0
},
jsPDF: { unit: 'mm', format: 'a4', orientation: 'landscape' },
pagebreak: { mode: ['avoid-all', 'css', 'legacy'] }
};
if (window.html2pdf) {
window.html2pdf().set(opt).from(exportArea).toPdf().get('pdf').then(function(pdf) {
// Si le document a créé une page blanche initiale ou superflue
const totalPages = pdf.internal.getNumberOfPages();
console.log("Pages générées :", totalPages);
}).save().then(() => {
scrollEl.style.maxHeight = prevMaxHeight;
scrollEl.style.overflow = prevOverflow;
scrollEl.scrollTop = prevScrollTop;
showToast("PDF téléchargé avec succès !");
}).catch((err) => {
scrollEl.style.maxHeight = prevMaxHeight;
scrollEl.style.overflow = prevOverflow;
scrollEl.scrollTop = prevScrollTop;
console.warn("Bascule vers impression navigateur :", err);
window.print();
});
} else {
scrollEl.style.maxHeight = prevMaxHeight;
scrollEl.style.overflow = prevOverflow;
window.print();
}
}
</script>
</body>
</html>