1148 lines
No EOL
52 KiB
HTML
1148 lines
No EOL
52 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Configuration initiale — GMAO{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-4">
|
|
<div class="row justify-content-center">
|
|
<div class="col-lg-10">
|
|
<div class="card shadow">
|
|
<div class="card-header bg-primary text-white">
|
|
<h4 class="mb-0"><i class="bi bi-gear-fill"></i> Configuration initiale GMAO</h4>
|
|
</div>
|
|
<div class="card-body">
|
|
<!-- Indicateurs d'étapes -->
|
|
<div class="d-flex justify-content-between mb-4 flex-wrap">
|
|
{% for i in range(1, 11) %}
|
|
<div class="text-center flex-fill" id="step{{ i }}-indicator" style="min-width: 60px;">
|
|
<i class="bi bi-{% if i <= progress.step %}check-circle-fill text-success{% else %}{{ i }}-circle{% if i == progress.step + 1 %} text-primary{% else %} text-secondary{% endif %}{% endif %} fs-3"></i>
|
|
<div class="small mt-1">
|
|
{% if i == 1 %}Admin
|
|
{% elif i == 2 %}Collège
|
|
{% elif i == 3 %}Bâtiments
|
|
{% elif i == 4 %}Lots
|
|
{% elif i == 5 %}Vacances
|
|
{% elif i == 6 %}ENT
|
|
{% elif i == 7 %}Pronote
|
|
{% elif i == 8 %}Salles
|
|
{% elif i == 9 %}Personnels
|
|
{% else %}Profs
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Étape 1: Administrateur -->
|
|
<div id="step1" class="step-panel" style="display:none;">
|
|
<h5 class="mb-3"><i class="bi bi-person-fill"></i> Compte administrateur</h5>
|
|
<p class="text-muted mb-3">Créez le compte administrateur principal de l'application.</p>
|
|
<form id="adminForm">
|
|
<div class="mb-3">
|
|
<label for="adminUsername" class="form-label">Nom d'utilisateur</label>
|
|
<input type="text" class="form-control" id="adminUsername" name="username"
|
|
placeholder="Ex: admin" required minlength="3"
|
|
value="{{ progress['data'].get('admin_username', '') }}">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="adminPassword" class="form-label">Mot de passe</label>
|
|
<div class="input-group">
|
|
<input type="password" class="form-control" id="adminPassword" name="password"
|
|
placeholder="Mot de passe" required minlength="4">
|
|
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword()">
|
|
<i class="bi bi-eye" id="passwordIcon"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Étape 2: Collège -->
|
|
<div id="step2" class="step-panel" style="display:none;">
|
|
<h5 class="mb-3"><i class="bi bi-building"></i> Informations du collège</h5>
|
|
<p class="text-muted mb-3">Renseignez les informations de votre établissement.</p>
|
|
<form id="collegeForm">
|
|
<div class="mb-3">
|
|
<label for="collegeName" class="form-label">Nom du collège</label>
|
|
<input type="text" class="form-control" id="collegeName" name="name"
|
|
placeholder="Ex: Collège Jean Moulin" required
|
|
value="{{ (progress['data'].get('college', {}) or {}).get('name', '') }}">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="collegeAddress" class="form-label">Adresse</label>
|
|
<input type="text" class="form-control" id="collegeAddress" name="address"
|
|
placeholder="Ex: 12 rue de la République" required
|
|
value="{{ (progress['data'].get('college', {}) or {}).get('address', '') }}">
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-4 mb-3">
|
|
<label for="collegeZip" class="form-label">Code postal</label>
|
|
<input type="text" class="form-control" id="collegeZip" name="zip"
|
|
placeholder="77000" required pattern="[0-9]{5}"
|
|
value="{{ (progress['data'].get('college', {}) or {}).get('zip', '') }}">
|
|
</div>
|
|
<div class="col-md-8 mb-3">
|
|
<label for="collegeCity" class="form-label">Ville</label>
|
|
<input type="text" class="form-control" id="collegeCity" name="city"
|
|
placeholder="Ex: Melun" required
|
|
value="{{ (progress['data'].get('college', {}) or {}).get('city', '') }}">
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Étape 3: Bâtiments -->
|
|
<div id="step3" class="step-panel" style="display:none;">
|
|
<h5 class="mb-3"><i class="bi bi-buildings"></i> Bâtiments</h5>
|
|
<p class="text-muted mb-3">Ajoutez les bâtiments de votre établissement.</p>
|
|
|
|
<div class="row mb-2 fw-bold">
|
|
<div class="col-md-5">Nom</div>
|
|
<div class="col-md-6">Description</div>
|
|
<div class="col-md-1"></div>
|
|
</div>
|
|
|
|
<div id="buildingsList">
|
|
<!-- Les bâtiments seront ajoutés ici dynamiquement -->
|
|
</div>
|
|
|
|
<button type="button" class="btn btn-outline-primary mb-3" onclick="addBuilding()">
|
|
<i class="bi bi-plus-circle"></i> Ajouter un bâtiment
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Étape 4: Lots de maintenance -->
|
|
<div id="step4" class="step-panel" style="display:none;">
|
|
<h5 class="mb-3"><i class="bi bi-list-check"></i> Catégories de maintenance par lot</h5>
|
|
<p class="text-muted mb-3">Sélectionnez les catégories actives pour chaque lot de maintenance.</p>
|
|
|
|
<div class="mb-2">
|
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="toggleAllCategories(true)">
|
|
<i class="bi bi-check-all"></i> Tout cocher
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleAllCategories(false)">
|
|
<i class="bi bi-x"></i> Tout décocher
|
|
</button>
|
|
</div>
|
|
|
|
<div class="border rounded" style="max-height: 500px; overflow-y: auto;">
|
|
<div id="lotsCategoriesList">
|
|
<!-- Les lots avec leurs catégories seront ajoutés ici -->
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<span class="badge bg-primary"><span id="selectedCategoriesCount">0</span> catégorie(s) sélectionnée(s)</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Étape 5: Vacances et horaires -->
|
|
<div id="step5" class="step-panel" style="display:none;">
|
|
<h5 class="mb-3"><i class="bi bi-calendar-week"></i> Vacances et horaires</h5>
|
|
|
|
<!-- Zone académique -->
|
|
<div class="card mb-3">
|
|
<div class="card-header">
|
|
<strong><i class="bi bi-geo-alt"></i> Zone académique</strong>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<label class="form-label">Votre zone académique</label>
|
|
<select class="form-select" id="academiqueZone" onchange="loadVacancesPreview()">
|
|
<option value="">-- Sélectionner --</option>
|
|
<option value="A">Zone A (Lyon, Grenoble, etc.)</option>
|
|
<option value="B">Zone B (Paris, Créteil, Versailles, etc.)</option>
|
|
<option value="C">Zone C (Bordeaux, Toulouse, etc.)</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Année scolaire</label>
|
|
<select class="form-select" id="anneeScolaire" onchange="loadVacancesPreview()">
|
|
<option value="2024">2024-2025</option>
|
|
<option value="2025">2025-2026</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-check mt-2">
|
|
<input class="form-check-input" type="checkbox" id="importAutoVacances" checked>
|
|
<label class="form-check-label" for="importAutoVacances">
|
|
Importer automatiquement les vacances scolaires officielles
|
|
</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="importAutoFeries" checked>
|
|
<label class="form-check-label" for="importAutoFeries">
|
|
Importer automatiquement les jours fériés
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Horaires de travail -->
|
|
<div class="card mb-3">
|
|
<div class="card-header">
|
|
<strong><i class="bi bi-clock"></i> Horaires de travail</strong>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="workScheduleGrid">
|
|
<!-- Rempli dynamiquement -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Étape 6: ENT77 -->
|
|
<div id="step6" class="step-panel" style="display:none;">
|
|
<h5 class="mb-3"><i class="bi bi-envelope"></i> Configuration ENT77</h5>
|
|
<div class="alert alert-info">
|
|
<i class="bi bi-info-circle"></i> Les identifiants ENT77 permettront de récupérer automatiquement les messages reçus pour créer des tâches intelligentes.
|
|
</div>
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<div class="row mb-3">
|
|
<div class="col-md-12">
|
|
<label class="form-label">Nom d'utilisateur ENT77</label>
|
|
<input type="text" class="form-control" id="entUsername" placeholder="prenom.nom">
|
|
</div>
|
|
</div>
|
|
<div class="row mb-3">
|
|
<div class="col-md-12">
|
|
<label class="form-label">Mot de passe ENT77</label>
|
|
<input type="password" class="form-control" id="entPassword" placeholder="••••••••">
|
|
</div>
|
|
</div>
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="entIsActive" checked>
|
|
<label class="form-check-label" for="entIsActive">
|
|
Activer la récupération automatique
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6 text-end">
|
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="testEntConnection()">
|
|
<i class="bi bi-wifi"></i> Tester la connexion
|
|
</button>
|
|
<span id="entTestResult" class="ms-2"></span>
|
|
</div>
|
|
</div>
|
|
<div id="entValidationMessage" class="alert" style="display:none;"></div>
|
|
</div>
|
|
</div>
|
|
<div class="form-check mt-2">
|
|
<input class="form-check-input" type="checkbox" id="skipEnt">
|
|
<label class="form-check-label text-muted" for="skipEnt">
|
|
Passer cette étape (configuration possible plus tard dans /ent/credentials)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Étape 7: Pronote -->
|
|
<div id="step7" class="step-panel" style="display:none;">
|
|
<h5 class="mb-3"><i class="bi bi-calendar-week"></i> Configuration Pronote</h5>
|
|
<div class="alert alert-info">
|
|
<i class="bi bi-info-circle"></i> La connexion Pronote permet de récupérer les emplois du temps pour une meilleure planification, et pré-ajoute les salles, personnels et professeurs.
|
|
</div>
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<div class="row mb-3">
|
|
<div class="col-md-12">
|
|
<label class="form-label">QR Code JSON</label>
|
|
<small class="text-muted d-block mb-1">Utilisez l'extension Chrome "Lecteur QR Code" pour extraire le JSON du QR code Pronote</small>
|
|
<textarea class="form-control" id="pronoteQrCode" rows="3" placeholder='{"url": "...", "_nom": "..."}'></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label">Code attribué au QR Code</label>
|
|
<input type="text" class="form-control" id="pronoteQrCodeId" placeholder="Code affiché sous le QR code">
|
|
<small class="text-muted">Le code numérique affiché sous le QR code dans Pronote</small>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Code PIN</label>
|
|
<input type="password" class="form-control" id="pronotePin" placeholder="••••">
|
|
<small class="text-muted">Votre code PIN personnel (4 chiffres)</small>
|
|
</div>
|
|
</div>
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="pronoteSyncSalles" checked>
|
|
<label class="form-check-label" for="pronoteSyncSalles">
|
|
Synchroniser les salles automatiquement
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6 text-end">
|
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="testPronoteConnection()">
|
|
<i class="bi bi-wifi"></i> Tester la connexion
|
|
</button>
|
|
<span id="pronoteTestResult" class="ms-2"></span>
|
|
</div>
|
|
</div>
|
|
<div id="pronoteValidationMessage" class="alert" style="display:none;"></div>
|
|
</div>
|
|
</div>
|
|
<div class="form-check mt-2">
|
|
<input class="form-check-input" type="checkbox" id="skipPronote">
|
|
<label class="form-check-label text-muted" for="skipPronote">
|
|
Passer cette étape (configuration possible plus tard dans /pronote/connect)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Étape 8: Salles (à venir) -->
|
|
<div id="step8" class="step-panel" style="display:none;">
|
|
<h5 class="mb-3"><i class="bi bi-door-open"></i> Salles</h5>
|
|
<div class="alert alert-secondary">
|
|
<i class="bi bi-clock"></i> <strong>Étape à venir</strong>
|
|
</div>
|
|
<div class="card mb-3">
|
|
<div class="card-body text-center py-5">
|
|
<i class="bi bi-door-open" style="font-size: 3rem; color: #ccc;"></i>
|
|
<p class="mt-3 text-muted">La configuration des salles sera disponible dans une prochaine version.</p>
|
|
<p class="text-muted small">
|
|
Cette étape permettra de :<br>
|
|
• Importer les salles depuis Pronote<br>
|
|
• Ajouter des salles manuellement<br>
|
|
• Définir les étages et bâtiments
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="form-check mt-2">
|
|
<input class="form-check-input" type="checkbox" id="skipSalles" checked>
|
|
<label class="form-check-label text-muted" for="skipSalles">
|
|
Passer cette étape pour l'instant
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Étape 9: Personnels de l'établissement (à venir) -->
|
|
<div id="step9" class="step-panel" style="display:none;">
|
|
<h5 class="mb-3"><i class="bi bi-people"></i> Personnels de l'établissement</h5>
|
|
<div class="alert alert-secondary">
|
|
<i class="bi bi-clock"></i> <strong>Étape à venir</strong>
|
|
</div>
|
|
<div class="card mb-3">
|
|
<div class="card-body text-center py-5">
|
|
<i class="bi bi-people" style="font-size: 3rem; color: #ccc;"></i>
|
|
<p class="mt-3 text-muted">La configuration des personnels sera disponible dans une prochaine version.</p>
|
|
<p class="text-muted small">
|
|
Cette étape permettra de :<br>
|
|
• Importer les personnels depuis Pronote<br>
|
|
• Ajouter des personnels manuellement<br>
|
|
• Définir les rôles et responsabilités
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="form-check mt-2">
|
|
<input class="form-check-input" type="checkbox" id="skipPersonnels" checked>
|
|
<label class="form-check-label text-muted" for="skipPersonnels">
|
|
Passer cette étape pour l'instant
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Étape 10: Professeurs (à venir) -->
|
|
<div id="step10" class="step-panel" style="display:none;">
|
|
<h5 class="mb-3"><i class="bi bi-mortarboard"></i> Professeurs</h5>
|
|
<div class="alert alert-secondary">
|
|
<i class="bi bi-clock"></i> <strong>Étape à venir</strong>
|
|
</div>
|
|
<div class="card mb-3">
|
|
<div class="card-body text-center py-5">
|
|
<i class="bi bi-mortarboard" style="font-size: 3rem; color: #ccc;"></i>
|
|
<p class="mt-3 text-muted">La configuration des professeurs sera disponible dans une prochaine version.</p>
|
|
<p class="text-muted small">
|
|
Cette étape permettra de :<br>
|
|
• Importer les professeurs depuis Pronote<br>
|
|
• Ajouter des professeurs manuellement<br>
|
|
• Associer les professeurs aux matières
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="form-check mt-2">
|
|
<input class="form-check-input" type="checkbox" id="skipProfesseurs" checked>
|
|
<label class="form-check-label text-muted" for="skipProfesseurs">
|
|
Passer cette étape pour l'instant
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Boutons de navigation -->
|
|
<div class="d-flex justify-content-between mt-4">
|
|
<button type="button" id="prevBtn" class="btn btn-secondary" onclick="prevStep()" style="display:none;">
|
|
<i class="bi bi-arrow-left"></i> Précédent
|
|
</button>
|
|
<button type="button" id="nextBtn" class="btn btn-primary" onclick="nextStep()">
|
|
Suivant <i class="bi bi-arrow-right"></i>
|
|
</button>
|
|
<button type="button" id="finishBtn" class="btn btn-success" onclick="finishSetup()" style="display:none;">
|
|
<i class="bi bi-check-lg"></i> Terminer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// currentStep = dernière étape validée (stockée en base)
|
|
// displayStep = étape actuellement affichée à l'écran
|
|
const totalSteps = 10;
|
|
let currentStep = {{ progress.step | default(0) }};
|
|
let displayStep = Math.min(currentStep + 1, totalSteps);
|
|
|
|
// Données du setup
|
|
const setupData = {{ progress['data'] | tojson | safe }};
|
|
let buildings = setupData.buildings || [];
|
|
let selectedCategories = setupData.categories || [];
|
|
let workSchedule = setupData.workSchedule || {};
|
|
let customClosures = setupData.customClosures || [];
|
|
let personalLeaves = setupData.personalLeaves || [];
|
|
|
|
// Lots avec leurs catégories (depuis le JSON)
|
|
const lotsData = {{ lots_data | tojson | safe }};
|
|
|
|
// Jours de la semaine
|
|
const weekdays = [
|
|
{id: 1, name: "Lundi"},
|
|
{id: 2, name: "Mardi"},
|
|
{id: 3, name: "Mercredi"},
|
|
{id: 4, name: "Jeudi"},
|
|
{id: 5, name: "Vendredi"},
|
|
{id: 6, name: "Samedi"},
|
|
{id: 7, name: "Dimanche"}
|
|
];
|
|
|
|
// Horaires par défaut
|
|
const defaultSchedule = {
|
|
am_start: "08:00",
|
|
am_end: "12:00",
|
|
pm_start: "13:00",
|
|
pm_end: "17:00"
|
|
};
|
|
|
|
function showStep(step) {
|
|
displayStep = step;
|
|
|
|
// Masquer toutes les étapes
|
|
document.querySelectorAll('.step-panel').forEach(el => el.style.display = 'none');
|
|
|
|
// Afficher l'étape courante
|
|
document.getElementById('step' + step).style.display = 'block';
|
|
|
|
// Mettre à jour les indicateurs
|
|
for (let i = 1; i <= totalSteps; i++) {
|
|
const indicator = document.getElementById('step' + i + '-indicator');
|
|
const icon = indicator.querySelector('i');
|
|
if (i < step) {
|
|
icon.className = 'bi bi-check-circle-fill fs-3 text-success';
|
|
} else if (i === step) {
|
|
icon.className = 'bi bi-' + i + '-circle fs-3 text-primary';
|
|
} else {
|
|
icon.className = 'bi bi-' + i + '-circle fs-3 text-secondary';
|
|
}
|
|
}
|
|
|
|
// Gérer les boutons
|
|
document.getElementById('prevBtn').style.display = step > 1 ? 'block' : 'none';
|
|
document.getElementById('nextBtn').style.display = step < totalSteps ? 'block' : 'none';
|
|
document.getElementById('finishBtn').style.display = step === totalSteps ? 'block' : 'none';
|
|
|
|
// Charger les données spécifiques à l'étape
|
|
if (step === 3 && document.getElementById('buildingsList').children.length === 0) {
|
|
loadBuildings();
|
|
}
|
|
if (step === 4 && document.getElementById('lotsCategoriesList').children.length === 0) {
|
|
loadLotsCategories();
|
|
}
|
|
if (step === 5 && document.getElementById('workScheduleGrid').children.length === 0) {
|
|
loadWorkSchedule();
|
|
}
|
|
}
|
|
|
|
function togglePassword() {
|
|
const input = document.getElementById('adminPassword');
|
|
const icon = document.getElementById('passwordIcon');
|
|
if (input.type === 'password') {
|
|
input.type = 'text';
|
|
icon.className = 'bi bi-eye-slash';
|
|
} else {
|
|
input.type = 'password';
|
|
icon.className = 'bi bi-eye';
|
|
}
|
|
}
|
|
|
|
// === Gestion des bâtiments ===
|
|
|
|
function addBuildingRow(name = '', description = '') {
|
|
const index = buildings.length;
|
|
buildings.push({name: name, description: description});
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'row mb-2 building-item';
|
|
div.id = 'building_' + index;
|
|
div.innerHTML = `
|
|
<div class="col-md-5">
|
|
<input type="text" class="form-control form-control-sm"
|
|
placeholder="Nom du bâtiment"
|
|
value="${name}"
|
|
onchange="updateBuilding(${index}, 'name', this.value)">
|
|
</div>
|
|
<div class="col-md-6">
|
|
<input type="text" class="form-control form-control-sm"
|
|
placeholder="Description (optionnel)"
|
|
value="${description}"
|
|
onchange="updateBuilding(${index}, 'description', this.value)">
|
|
</div>
|
|
<div class="col-md-1">
|
|
<button type="button" class="btn btn-sm btn-danger" onclick="removeBuilding(${index}, this)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
document.getElementById('buildingsList').appendChild(div);
|
|
}
|
|
|
|
function addBuilding() {
|
|
addBuildingRow('', '');
|
|
}
|
|
|
|
function updateBuilding(index, field, value) {
|
|
if (buildings[index]) {
|
|
buildings[index][field] = value.trim();
|
|
}
|
|
}
|
|
|
|
function removeBuilding(index, button) {
|
|
buildings[index] = null;
|
|
const div = button.closest('.building-item');
|
|
if (div) {
|
|
div.remove();
|
|
}
|
|
}
|
|
|
|
function loadBuildings() {
|
|
const list = document.getElementById('buildingsList');
|
|
list.innerHTML = '';
|
|
buildings.forEach(b => {
|
|
if (b) addBuildingRow(b.name || '', b.description || '');
|
|
});
|
|
}
|
|
|
|
// === Gestion des catégories par lot ===
|
|
|
|
function loadLotsCategories() {
|
|
const container = document.getElementById('lotsCategoriesList');
|
|
container.innerHTML = '';
|
|
|
|
const sortedLots = Object.keys(lotsData).sort();
|
|
|
|
sortedLots.forEach(lotName => {
|
|
const categories = lotsData[lotName];
|
|
const sortedCategories = Object.keys(categories).sort();
|
|
|
|
if (sortedCategories.length === 0) return;
|
|
|
|
// Créer l'accordéon pour le lot
|
|
const lotDiv = document.createElement('div');
|
|
lotDiv.className = 'border-bottom';
|
|
|
|
// En-tête du lot
|
|
const headerDiv = document.createElement('div');
|
|
headerDiv.className = 'd-flex justify-content-between align-items-center p-2 bg-light';
|
|
headerDiv.style.cursor = 'pointer';
|
|
headerDiv.innerHTML = `
|
|
<div class="d-flex align-items-center">
|
|
<i class="bi bi-chevron-right me-2 lot-chevron"></i>
|
|
<strong>${lotName}</strong>
|
|
<span class="badge bg-secondary ms-2">${sortedCategories.length} catégorie(s)</span>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input lot-check-all" type="checkbox"
|
|
id="lot_all_${lotName.replace(/\s/g, '_')}"
|
|
onchange="toggleAllLotCategories('${lotName}', this.checked)">
|
|
<label class="form-check-label small" for="lot_all_${lotName.replace(/\s/g, '_')}">Tout le lot</label>
|
|
</div>
|
|
`;
|
|
|
|
// Contenu des catégories
|
|
const contentDiv = document.createElement('div');
|
|
contentDiv.className = 'p-2';
|
|
contentDiv.style.display = 'none';
|
|
|
|
sortedCategories.forEach(catName => {
|
|
const catKey = `${lotName}|||${catName}`;
|
|
const checked = selectedCategories.includes(catKey);
|
|
const taches = categories[catName];
|
|
|
|
const catDiv = document.createElement('div');
|
|
catDiv.className = 'form-check ms-4 mb-1';
|
|
catDiv.innerHTML = `
|
|
<input class="form-check-input category-checkbox" type="checkbox"
|
|
id="cat_${lotName.replace(/\s/g, '_')}_${catName.replace(/\s/g, '_')}"
|
|
value="${catKey}" ${checked ? 'checked' : ''}
|
|
data-lot="${lotName}"
|
|
onchange="toggleCategory('${catKey}', this.checked)">
|
|
<label class="form-check-label" for="cat_${lotName.replace(/\s/g, '_')}_${catName.replace(/\s/g, '_')}">
|
|
${catName} <small class="text-muted">(${taches.length} tâche${taches.length > 1 ? 's' : ''})</small>
|
|
</label>
|
|
`;
|
|
contentDiv.appendChild(catDiv);
|
|
});
|
|
|
|
// Toggle accordéon
|
|
headerDiv.addEventListener('click', function(e) {
|
|
if (e.target.type === 'checkbox' || e.target.tagName === 'LABEL') return;
|
|
const chevron = headerDiv.querySelector('.lot-chevron');
|
|
if (contentDiv.style.display === 'none') {
|
|
contentDiv.style.display = 'block';
|
|
chevron.className = 'bi bi-chevron-down me-2 lot-chevron';
|
|
} else {
|
|
contentDiv.style.display = 'none';
|
|
chevron.className = 'bi bi-chevron-right me-2 lot-chevron';
|
|
}
|
|
});
|
|
|
|
lotDiv.appendChild(headerDiv);
|
|
lotDiv.appendChild(contentDiv);
|
|
container.appendChild(lotDiv);
|
|
});
|
|
|
|
updateSelectedCategoriesCount();
|
|
}
|
|
|
|
function toggleCategory(catKey, checked) {
|
|
if (checked) {
|
|
if (!selectedCategories.includes(catKey)) {
|
|
selectedCategories.push(catKey);
|
|
}
|
|
} else {
|
|
selectedCategories = selectedCategories.filter(c => c !== catKey);
|
|
}
|
|
updateSelectedCategoriesCount();
|
|
updateLotCheckAll();
|
|
}
|
|
|
|
function toggleAllLotCategories(lotName, checked) {
|
|
const checkboxes = document.querySelectorAll(`.category-checkbox[data-lot="${lotName}"]`);
|
|
checkboxes.forEach(cb => {
|
|
cb.checked = checked;
|
|
const catKey = cb.value;
|
|
if (checked) {
|
|
if (!selectedCategories.includes(catKey)) {
|
|
selectedCategories.push(catKey);
|
|
}
|
|
} else {
|
|
selectedCategories = selectedCategories.filter(c => c !== catKey);
|
|
}
|
|
});
|
|
updateSelectedCategoriesCount();
|
|
}
|
|
|
|
function toggleAllCategories(checked) {
|
|
const checkboxes = document.querySelectorAll('.category-checkbox');
|
|
checkboxes.forEach(cb => {
|
|
cb.checked = checked;
|
|
const catKey = cb.value;
|
|
if (checked) {
|
|
if (!selectedCategories.includes(catKey)) {
|
|
selectedCategories.push(catKey);
|
|
}
|
|
} else {
|
|
selectedCategories = selectedCategories.filter(c => c !== catKey);
|
|
}
|
|
});
|
|
|
|
// Mettre à jour les checkboxes "tout le lot"
|
|
document.querySelectorAll('.lot-check-all').forEach(cb => {
|
|
cb.checked = checked;
|
|
});
|
|
|
|
updateSelectedCategoriesCount();
|
|
}
|
|
|
|
function updateSelectedCategoriesCount() {
|
|
document.getElementById('selectedCategoriesCount').textContent = selectedCategories.length;
|
|
}
|
|
|
|
function updateLotCheckAll() {
|
|
document.querySelectorAll('.lot-check-all').forEach(cb => {
|
|
const lotName = cb.id.replace('lot_all_', '').replace(/_/g, ' ');
|
|
const catCheckboxes = document.querySelectorAll(`.category-checkbox[data-lot="${lotName}"]`);
|
|
const allChecked = Array.from(catCheckboxes).every(c => c.checked);
|
|
cb.checked = allChecked;
|
|
});
|
|
}
|
|
|
|
// === Gestion de l'étape 5 : Vacances et horaires ===
|
|
|
|
function loadWorkSchedule() {
|
|
const container = document.getElementById('workScheduleGrid');
|
|
container.innerHTML = '';
|
|
|
|
// En-têtes
|
|
const headerRow = document.createElement('div');
|
|
headerRow.className = 'row fw-bold mb-2 pb-2 border-bottom';
|
|
headerRow.innerHTML = `
|
|
<div class="col-md-2">Jour</div>
|
|
<div class="col-md-2 text-center">Travail</div>
|
|
<div class="col-md-4">Matin</div>
|
|
<div class="col-md-4">Après-midi</div>
|
|
`;
|
|
container.appendChild(headerRow);
|
|
|
|
// Lignes pour chaque jour
|
|
weekdays.forEach(day => {
|
|
const schedule = workSchedule[day.id] || {
|
|
is_working: day.id <= 5, // Lundi-Vendredi par défaut
|
|
am_start: defaultSchedule.am_start,
|
|
am_end: defaultSchedule.am_end,
|
|
pm_start: defaultSchedule.pm_start,
|
|
pm_end: defaultSchedule.pm_end,
|
|
am_working: true,
|
|
pm_working: true
|
|
};
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'row mb-2 align-items-center';
|
|
row.id = `schedule_row_${day.id}`;
|
|
row.innerHTML = `
|
|
<div class="col-md-2">
|
|
<strong>${day.name}</strong>
|
|
</div>
|
|
<div class="col-md-2 text-center">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="working_${day.id}"
|
|
${schedule.is_working ? 'checked' : ''}
|
|
onchange="toggleWorkingDay(${day.id})">
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<input type="time" class="form-control form-control-sm" id="am_start_${day.id}"
|
|
value="${schedule.am_start}" ${!schedule.is_working ? 'disabled' : ''}>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<input type="time" class="form-control form-control-sm" id="am_end_${day.id}"
|
|
value="${schedule.am_end}" ${!schedule.is_working ? 'disabled' : ''}>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<input type="time" class="form-control form-control-sm" id="pm_start_${day.id}"
|
|
value="${schedule.pm_start}" ${!schedule.is_working ? 'disabled' : ''}>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<input type="time" class="form-control form-control-sm" id="pm_end_${day.id}"
|
|
value="${schedule.pm_end}" ${!schedule.is_working ? 'disabled' : ''}>
|
|
</div>
|
|
`;
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function toggleWorkingDay(dayId) {
|
|
const checkbox = document.getElementById(`working_${dayId}`);
|
|
const inputs = document.querySelectorAll(`#schedule_row_${dayId} input[type="time"]`);
|
|
inputs.forEach(input => {
|
|
input.disabled = !checkbox.checked;
|
|
});
|
|
}
|
|
|
|
function getWorkScheduleData() {
|
|
const schedule = {};
|
|
weekdays.forEach(day => {
|
|
schedule[day.id] = {
|
|
is_working: document.getElementById(`working_${day.id}`).checked,
|
|
am_start: document.getElementById(`am_start_${day.id}`).value,
|
|
am_end: document.getElementById(`am_end_${day.id}`).value,
|
|
pm_start: document.getElementById(`pm_start_${day.id}`).value,
|
|
pm_end: document.getElementById(`pm_end_${day.id}`).value
|
|
};
|
|
});
|
|
return schedule;
|
|
}
|
|
|
|
// === Navigation ===
|
|
|
|
function nextStep() {
|
|
if (displayStep === 1) {
|
|
// Valider le formulaire admin
|
|
const username = document.getElementById('adminUsername').value.trim();
|
|
const password = document.getElementById('adminPassword').value;
|
|
|
|
if (username.length < 3) {
|
|
alert('Le nom d\'utilisateur doit faire au moins 3 caractères');
|
|
return;
|
|
}
|
|
|
|
if (password.length < 4) {
|
|
alert('Le mot de passe doit faire au moins 4 caractères');
|
|
return;
|
|
}
|
|
|
|
saveStep(1, {username, password}).then(success => {
|
|
if (success) {
|
|
currentStep = 1;
|
|
showStep(2);
|
|
}
|
|
});
|
|
} else if (displayStep === 2) {
|
|
// Valider le formulaire collège
|
|
const name = document.getElementById('collegeName').value.trim();
|
|
const address = document.getElementById('collegeAddress').value.trim();
|
|
const zip = document.getElementById('collegeZip').value.trim();
|
|
const city = document.getElementById('collegeCity').value.trim();
|
|
|
|
if (!name) {
|
|
alert('Le nom du collège est requis');
|
|
return;
|
|
}
|
|
|
|
if (!address) {
|
|
alert('L\'adresse est requise');
|
|
return;
|
|
}
|
|
|
|
if (!/^\d{5}$/.test(zip)) {
|
|
alert('Le code postal doit contenir 5 chiffres');
|
|
return;
|
|
}
|
|
|
|
if (!city) {
|
|
alert('La ville est requise');
|
|
return;
|
|
}
|
|
|
|
saveStep(2, {name, address, zip, city}).then(success => {
|
|
if (success) {
|
|
currentStep = 2;
|
|
showStep(3);
|
|
}
|
|
});
|
|
} else if (displayStep === 3) {
|
|
// Valider les bâtiments
|
|
const validBuildings = buildings.filter(b => b && b.name && b.name.trim());
|
|
|
|
if (validBuildings.length === 0) {
|
|
alert('Ajoutez au moins un bâtiment');
|
|
return;
|
|
}
|
|
|
|
saveStep(3, {buildings: validBuildings}).then(success => {
|
|
if (success) {
|
|
currentStep = 3;
|
|
showStep(4);
|
|
}
|
|
});
|
|
} else if (displayStep === 4) {
|
|
// Valider les catégories
|
|
if (selectedCategories.length === 0) {
|
|
alert('Sélectionnez au moins une catégorie');
|
|
return;
|
|
}
|
|
|
|
saveStep(4, {categories: selectedCategories}).then(success => {
|
|
if (success) {
|
|
currentStep = 4;
|
|
showStep(5);
|
|
}
|
|
});
|
|
} else if (displayStep === 5) {
|
|
// Étape 5 : Vacances et horaires
|
|
const zone = document.getElementById('academiqueZone').value;
|
|
const annee = document.getElementById('anneeScolaire').value;
|
|
const importVacances = document.getElementById('importAutoVacances').checked;
|
|
const importFeries = document.getElementById('importAutoFeries').checked;
|
|
const workData = getWorkScheduleData();
|
|
|
|
const stepData = {
|
|
zone: zone,
|
|
annee: annee,
|
|
import_vacances: importVacances,
|
|
import_feries: importFeries,
|
|
work_schedule: workData
|
|
};
|
|
|
|
saveStep(5, stepData).then(success => {
|
|
if (success) {
|
|
currentStep = 5;
|
|
showStep(6);
|
|
}
|
|
});
|
|
} else if (displayStep === 6) {
|
|
// Étape 6 : ENT77
|
|
const skipEnt = document.getElementById('skipEnt').checked;
|
|
const entUsername = document.getElementById('entUsername').value.trim();
|
|
const entPassword = document.getElementById('entPassword').value;
|
|
const entIsActive = document.getElementById('entIsActive').checked;
|
|
|
|
if (skipEnt) {
|
|
// Passer l'étape ENT
|
|
currentStep = 6;
|
|
showStep(7);
|
|
return;
|
|
}
|
|
|
|
if (!entUsername || !entPassword) {
|
|
alert('Remplissez les identifiants ENT77 ou cochez "Passer cette étape"');
|
|
return;
|
|
}
|
|
|
|
// Tester la connexion avant de sauvegarder
|
|
testEntAndSave(entUsername, entPassword, entIsActive);
|
|
} else if (displayStep === 7) {
|
|
// Étape 7 : Pronote
|
|
const skipPronote = document.getElementById('skipPronote').checked;
|
|
const qrCode = document.getElementById('pronoteQrCode').value.trim();
|
|
const qrCodeId = document.getElementById('pronoteQrCodeId').value.trim();
|
|
const pin = document.getElementById('pronotePin').value;
|
|
const syncSalles = document.getElementById('pronoteSyncSalles').checked;
|
|
|
|
if (skipPronote) {
|
|
// Passer l'étape Pronote
|
|
currentStep = 7;
|
|
showStep(8);
|
|
return;
|
|
}
|
|
|
|
if (!qrCode || !qrCodeId || !pin) {
|
|
alert('Remplissez tous les champs Pronote ou cochez "Passer cette étape"');
|
|
return;
|
|
}
|
|
|
|
// Tester la connexion avant de sauvegarder
|
|
testPronoteAndSave(qrCode, qrCodeId, pin, syncSalles);
|
|
} else if (displayStep === 8) {
|
|
// Étape 8 : Salles (à venir) - passer automatiquement
|
|
currentStep = 8;
|
|
showStep(9);
|
|
} else if (displayStep === 9) {
|
|
// Étape 9 : Personnels (à venir) - passer automatiquement
|
|
currentStep = 9;
|
|
showStep(10);
|
|
} else if (displayStep === 10) {
|
|
// Étape 10 : Professeurs (à venir) - terminer
|
|
finishSetup();
|
|
} else {
|
|
}
|
|
}
|
|
|
|
function prevStep() {
|
|
if (displayStep > 1) {
|
|
showStep(displayStep - 1);
|
|
}
|
|
}
|
|
|
|
async function saveStep(step, data) {
|
|
try {
|
|
const response = await fetch('/setup-wizard/api/step/' + step, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.error) {
|
|
alert(result.error);
|
|
return false;
|
|
}
|
|
|
|
if (result.success) {
|
|
// Mettre à jour les données locales
|
|
if (step === 1) {
|
|
setupData.admin_username = data.username;
|
|
} else if (step === 2) {
|
|
setupData.college = data;
|
|
} else if (step === 3) {
|
|
setupData.buildings = data.buildings;
|
|
} else if (step === 4) {
|
|
setupData.categories = data.categories;
|
|
} else if (step === 5) {
|
|
setupData.workSchedule = data.work_schedule;
|
|
setupData.customClosures = data.custom_closures;
|
|
setupData.personalLeaves = data.personal_leaves;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} catch (e) {
|
|
alert('Erreur: ' + e.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// === Fonctions pour l'étape 6: ENT77 ===
|
|
async function testEntConnection() {
|
|
const username = document.getElementById('entUsername').value.trim();
|
|
const password = document.getElementById('entPassword').value;
|
|
|
|
if (!username || !password) {
|
|
showEntMessage('Remplissez les identifiants pour tester', 'warning');
|
|
return;
|
|
}
|
|
|
|
document.getElementById('entTestResult').innerHTML = '<i class="bi bi-hourglass-split"></i> Test...';
|
|
|
|
try {
|
|
const response = await fetch('/setup-wizard/api/test-ent', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({username, password})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
document.getElementById('entTestResult').innerHTML = '<span class="text-success"><i class="bi bi-check-circle"></i> OK</span>';
|
|
showEntMessage(result.message || 'Connexion réussie', 'success');
|
|
} else {
|
|
document.getElementById('entTestResult').innerHTML = '<span class="text-danger"><i class="bi bi-x-circle"></i> Échec</span>';
|
|
showEntMessage(result.error || 'Échec de connexion', 'danger');
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('entTestResult').innerHTML = '<span class="text-danger"><i class="bi bi-x-circle"></i> Erreur</span>';
|
|
showEntMessage('Erreur de connexion au serveur', 'danger');
|
|
}
|
|
}
|
|
|
|
async function testEntAndSave(username, password, isActive) {
|
|
document.getElementById('entTestResult').innerHTML = '<i class="bi bi-hourglass-split"></i> Test...';
|
|
|
|
try {
|
|
const response = await fetch('/setup-wizard/api/test-ent', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({username, password})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
document.getElementById('entTestResult').innerHTML = '<span class="text-success"><i class="bi bi-check-circle"></i> OK</span>';
|
|
showEntMessage(result.message || 'Connexion réussie ! Enregistrement...', 'success');
|
|
|
|
// Sauvegarder et passer à l'étape suivante
|
|
await saveStep(6, {username, password, is_active: isActive});
|
|
currentStep = 6;
|
|
showStep(7);
|
|
} else {
|
|
document.getElementById('entTestResult').innerHTML = '<span class="text-danger"><i class="bi bi-x-circle"></i> Échec</span>';
|
|
showEntMessage(result.error || 'Échec de connexion', 'danger');
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('entTestResult').innerHTML = '<span class="text-danger"><i class="bi bi-x-circle"></i> Erreur</span>';
|
|
showEntMessage('Erreur de connexion au serveur', 'danger');
|
|
}
|
|
}
|
|
|
|
function showEntMessage(message, type) {
|
|
const div = document.getElementById('entValidationMessage');
|
|
div.className = 'alert alert-' + type;
|
|
div.textContent = message;
|
|
div.style.display = 'block';
|
|
}
|
|
|
|
// === Fonctions pour l'étape 7: Pronote ===
|
|
async function testPronoteConnection() {
|
|
const qrCode = document.getElementById('pronoteQrCode').value.trim();
|
|
const qrCodeId = document.getElementById('pronoteQrCodeId').value.trim();
|
|
const pin = document.getElementById('pronotePin').value;
|
|
|
|
if (!qrCode || !qrCodeId || !pin) {
|
|
showPronoteMessage('Remplissez tous les champs pour tester', 'warning');
|
|
return;
|
|
}
|
|
|
|
document.getElementById('pronoteTestResult').innerHTML = '<i class="bi bi-hourglass-split"></i> Test...';
|
|
|
|
try {
|
|
const response = await fetch('/setup-wizard/api/test-pronote', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({qr_code: qrCode, qr_code_id: qrCodeId, pin})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
document.getElementById('pronoteTestResult').innerHTML = '<span class="text-success"><i class="bi bi-check-circle"></i> OK</span>';
|
|
showPronoteMessage(result.message || 'Connexion réussie', 'success');
|
|
} else {
|
|
document.getElementById('pronoteTestResult').innerHTML = '<span class="text-danger"><i class="bi bi-x-circle"></i> Échec</span>';
|
|
showPronoteMessage(result.error || 'Échec de connexion', 'danger');
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('pronoteTestResult').innerHTML = '<span class="text-danger"><i class="bi bi-x-circle"></i> Erreur</span>';
|
|
showPronoteMessage('Erreur de connexion au serveur', 'danger');
|
|
}
|
|
}
|
|
|
|
async function testPronoteAndSave(qrCode, qrCodeId, pin, syncSalles) {
|
|
document.getElementById('pronoteTestResult').innerHTML = '<i class="bi bi-hourglass-split"></i> Test...';
|
|
|
|
try {
|
|
const response = await fetch('/setup-wizard/api/test-pronote', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({qr_code: qrCode, qr_code_id: qrCodeId, pin})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
document.getElementById('pronoteTestResult').innerHTML = '<span class="text-success"><i class="bi bi-check-circle"></i> OK</span>';
|
|
showPronoteMessage(result.message || 'Connexion réussie ! Enregistrement...', 'success');
|
|
|
|
// Sauvegarder les tokens
|
|
await saveStep(7, {qr_code: qrCode, qr_code_id: qrCodeId, pin, sync_salles: syncSalles});
|
|
|
|
// Passer à l'étape suivante
|
|
showStep(8);
|
|
} else {
|
|
document.getElementById('pronoteTestResult').innerHTML = '<span class="text-danger"><i class="bi bi-x-circle"></i> Échec</span>';
|
|
showPronoteMessage(result.error || 'Échec de connexion', 'danger');
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('pronoteTestResult').innerHTML = '<span class="text-danger"><i class="bi bi-x-circle"></i> Erreur</span>';
|
|
showPronoteMessage('Erreur de connexion au serveur', 'danger');
|
|
}
|
|
}
|
|
|
|
function showPronoteMessage(message, type) {
|
|
const div = document.getElementById('pronoteValidationMessage');
|
|
div.className = 'alert alert-' + type;
|
|
div.textContent = message;
|
|
div.style.display = 'block';
|
|
}
|
|
|
|
function finishSetup() {
|
|
// Appeler l'API pour finaliser le setup
|
|
fetch('/setup-wizard/api/finish', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'}
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
alert('Configuration terminée ! L\'application va redémarrer.');
|
|
window.location.href = '/';
|
|
} else {
|
|
alert('Erreur: ' + (data.error || 'Erreur inconnue'));
|
|
}
|
|
})
|
|
.catch(e => {
|
|
alert('Erreur de connexion au serveur');
|
|
});
|
|
}
|
|
|
|
// Initialiser
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
displayStep = Math.min(currentStep + 1, totalSteps);
|
|
showStep(displayStep);
|
|
});
|
|
</script>
|
|
{% endblock %} |