2026-08-14 18:02:25 +02:00
{% extends "base.html" %}
{% block title %}Configuration initiale — GMAO{% endblock %}
{% block content %}
< div class = "container-fluid py-4" >
2026-08-14 20:20:16 +02:00
< div class = "card shadow mx-auto" style = "max-width: 1200px" >
< div class = "card-header bg-primary text-white" >
< h4 class = "mb-0" > < i class = "bi bi-gear-fill" > < / i > Configuration initiale GMAO< / h4 >
2026-08-22 00:26:13 +02:00
< div class = "small mt-1 opacity-75" > Version < code class = "text-white" > v{{ gmao_version }}< / code > · Commit déployé : < code class = "text-white" > {{ build_commit }}< / code > < / div >
2026-08-14 20:20:16 +02:00
< / div >
< div class = "card-body" >
< div class = "d-flex flex-wrap justify-content-between gap-2 mb-4" id = "stepIndicators" >
{% set labels = ['Admin', 'Collège', 'Bâtiments', 'Zones', 'Salles', 'Lots', 'Calendrier', 'ENT', 'Pronote', 'Fin'] %}
{% for label in labels %}
< div class = "text-center flex-fill small" id = "step{{ loop.index }}-indicator" >
< i class = "bi bi-{{ loop.index }}-circle fs-4" > < / i > < div > {{ label }}< / div >
< / div >
{% endfor %}
< / div >
2026-08-14 18:02:25 +02:00
2026-08-14 20:20:16 +02:00
< div id = "step1" class = "step-panel" >
< h5 > Compte administrateur< / h5 >
< div class = "mb-3" > < label class = "form-label" for = "adminUsername" > Nom d’ utilisateur< / label >
< input class = "form-control" id = "adminUsername" minlength = "3" value = "{{ progress.data.admin_username }}" required >
< / div >
< div class = "mb-3" > < label class = "form-label" for = "adminPassword" > Mot de passe< / label >
< input type = "password" class = "form-control" id = "adminPassword" minlength = "12" autocomplete = "new-password" required >
< div class = "form-text" > 12 caractères minimum.< / div >
< / div >
< / div >
< div id = "step2" class = "step-panel d-none" >
< h5 > Établissement< / h5 >
< div class = "mb-2" > < label class = "form-label" for = "collegeName" > Nom< / label > < input class = "form-control" id = "collegeName" value = "{{ progress.data.college.name }}" > < / div >
< div class = "mb-2" > < label class = "form-label" for = "collegeAddress" > Adresse< / label > < input class = "form-control" id = "collegeAddress" value = "{{ progress.data.college.address }}" > < / div >
< div class = "row" > < div class = "col-md-4 mb-2" > < label class = "form-label" for = "collegeZip" > Code postal< / label > < input class = "form-control" id = "collegeZip" value = "{{ progress.data.college.zip }}" > < / div >
< div class = "col-md-8 mb-2" > < label class = "form-label" for = "collegeCity" > Ville< / label > < input class = "form-control" id = "collegeCity" value = "{{ progress.data.college.city }}" > < / div > < / div >
< / div >
< div id = "step3" class = "step-panel d-none" >
< h5 > Bâtiments< / h5 >
< p class = "text-muted" > Les bâtiments existants sont chargés et mis à jour. Retirer une ligne de l’ écran ne supprime jamais le bâtiment ni ses locaux.< / p >
< div id = "buildingsList" > < / div >
< button class = "btn btn-outline-primary btn-sm" type = "button" onclick = "addBuilding()" > < i class = "bi bi-plus-circle" > < / i > Ajouter< / button >
< / div >
< div id = "step4" class = "step-panel d-none" >
< h5 > Zones< / h5 >
< p class = "text-muted" > Chaque zone appartient à un bâtiment. Les zones déjà importées sont conservées.< / p >
< div id = "zonesList" > < / div >
< button class = "btn btn-outline-primary btn-sm" type = "button" onclick = "addZone()" > < i class = "bi bi-plus-circle" > < / i > Ajouter< / button >
< / div >
< div id = "step5" class = "step-panel d-none" >
< h5 > Salles et locaux< / h5 >
< p class = "text-muted" > Une salle peut être affectée à une zone, avec son étage et son code. Les salles existantes ne sont jamais supprimées par le wizard.< / p >
< div class = "table-responsive" > < table class = "table table-sm align-middle" > < thead > < tr > < th > Nom< / th > < th > Code< / th > < th > Bâtiment< / th > < th > Zone< / th > < th > Étage< / th > < th > < / th > < / tr > < / thead > < tbody id = "roomsList" > < / tbody > < / table > < / div >
< button class = "btn btn-outline-primary btn-sm" type = "button" onclick = "addRoom()" > < i class = "bi bi-plus-circle" > < / i > Ajouter< / button >
< / div >
< div id = "step6" class = "step-panel d-none" >
< h5 > Catalogue des lots et catégories< / h5 >
< div class = "alert alert-info" > Le catalogue issu du dump SQL est intégré à l’ application. Cette étape crée les entrées manquantes et corrige leurs associations sans supprimer les données existantes.< / div >
< div class = "row mb-3" > < div class = "col" > < strong > {{ lots_data|length }}< / strong > catégories< / div > < div class = "col" > < strong > {{ lots_data.values()|map('length')|sum }}< / strong > lots< / div > < / div >
< div class = "accordion" id = "catalogAccordion" >
{% for category, lots in lots_data.items() %}
< div class = "accordion-item" > < h2 class = "accordion-header" > < button class = "accordion-button collapsed" type = "button" data-bs-toggle = "collapse" data-bs-target = "#catalog{{ loop.index }}" > {{ category }} < span class = "badge bg-secondary ms-2" > {{ lots|length }}< / span > < / button > < / h2 >
< div id = "catalog{{ loop.index }}" class = "accordion-collapse collapse" data-bs-parent = "#catalogAccordion" > < div class = "accordion-body" > < ul class = "mb-0" > {% for lot in lots %}< li > {{ lot }}< / li > {% endfor %}< / ul > < / div > < / div > < / div >
{% endfor %}
2026-08-14 18:02:25 +02:00
< / div >
2026-08-14 20:20:16 +02:00
< / div >
< div id = "step7" class = "step-panel d-none" >
< h5 > Calendrier< / h5 >
2026-08-14 20:43:10 +02:00
< div class = "alert alert-info" >
Les vacances scolaires sont considérées comme non travaillées. Ajoutez uniquement les journées où vous êtes présent ; chaque journée peut avoir des horaires différents.
< / div >
2026-08-14 20:20:16 +02:00
< div class = "row" > < div class = "col-md-6" > < label class = "form-label" for = "academiqueZone" > Zone académique< / label > < select class = "form-select" id = "academiqueZone" > < option value = "A" > Zone A< / option > < option value = "B" > Zone B< / option > < option value = "C" > Zone C< / option > < / select > < / div >
< div class = "col-md-6" > < label class = "form-label" for = "anneeScolaire" > Année de début< / label > < input type = "number" class = "form-control" id = "anneeScolaire" min = "2024" value = "{{ progress.data.vacances_annee or 2026 }}" > < / div > < / div >
2026-08-14 20:43:10 +02:00
< div class = "form-check mt-3" > < input class = "form-check-input" type = "checkbox" id = "importAutoVacances" checked > < label class = "form-check-label" for = "importAutoVacances" > Importer les dates officielles dès que l’ importeur académique sera disponible< / label > < / div >
2026-08-14 20:20:16 +02:00
< div class = "form-check" > < input class = "form-check-input" type = "checkbox" id = "importAutoFeries" checked > < label class = "form-check-label" for = "importAutoFeries" > Importer les jours fériés lorsqu’ un importeur sera configuré< / label > < / div >
2026-08-14 20:43:10 +02:00
< hr >
< div class = "d-flex justify-content-between align-items-center mb-2" > < h6 class = "mb-0" > Périodes de vacances et présences exceptionnelles< / h6 > < button type = "button" class = "btn btn-outline-primary btn-sm" onclick = "addVacationPeriod()" > < i class = "bi bi-plus-circle" > < / i > Ajouter une période< / button > < / div >
< div id = "vacationPeriodsList" > < / div >
2026-08-14 20:20:16 +02:00
< / div >
< div id = "step8" class = "step-panel d-none" > < h5 > ENT< / h5 > < div class = "alert alert-secondary" > Configuration facultative. Elle sera réalisée depuis la page dédiée après l’ installation ; tant qu’ elle est absente, son watchdog reste arrêté.< / div > < / div >
< div id = "step9" class = "step-panel d-none" > < h5 > Pronote< / h5 > < div class = "alert alert-secondary" > Configuration facultative. Elle sera réalisée depuis la page dédiée après l’ installation ; tant qu’ elle est absente, son watchdog reste arrêté.< / div > < / div >
< div id = "step10" class = "step-panel d-none" > < h5 > Terminer< / h5 > < div class = "alert alert-success" > Vérifiez les étapes précédentes, puis finalisez l’ installation.< / div > < / div >
< div class = "d-flex justify-content-between mt-4" >
< button id = "prevBtn" class = "btn btn-secondary" type = "button" onclick = "previousStep()" > < i class = "bi bi-arrow-left" > < / i > Précédent< / button >
< button id = "nextBtn" class = "btn btn-primary" type = "button" onclick = "nextStep()" > Suivant < i class = "bi bi-arrow-right" > < / i > < / button >
< button id = "finishBtn" class = "btn btn-success d-none" type = "button" onclick = "finishSetup()" > < i class = "bi bi-check-lg" > < / i > Terminer< / button >
< / div >
2026-08-14 18:02:25 +02:00
< / div >
2026-08-14 20:20:16 +02:00
< / div >
2026-08-14 18:02:25 +02:00
< / div >
< script >
const totalSteps = 10;
2026-08-14 20:20:16 +02:00
let displayStep = Math.min(Math.max(Number({{ progress.step|default(0) }}) + 1, 1), totalSteps);
const setupData = {{ progress.data|tojson }};
2026-08-14 18:02:25 +02:00
let buildings = setupData.buildings || [];
2026-08-14 20:20:16 +02:00
let zones = setupData.zones || [];
let rooms = setupData.rooms || [];
2026-08-14 20:43:10 +02:00
let vacationPeriods = setupData.vacation_periods || [];
2026-08-14 20:20:16 +02:00
function field(value, placeholder, type = 'text') {
const input = document.createElement('input');
input.className = 'form-control form-control-sm'; input.type = type;
input.value = value ?? ''; input.placeholder = placeholder; return input;
}
function selectField(options, value, emptyLabel = null) {
const select = document.createElement('select'); select.className = 'form-select form-select-sm';
if (emptyLabel !== null) select.add(new Option(emptyLabel, ''));
options.forEach(item => select.add(new Option(item.label, String(item.value))));
select.value = value == null ? '' : String(value); return select;
}
function removeButton(callback) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-outline-secondary btn-sm';
button.title = 'Retirer de cette saisie sans supprimer en base'; button.innerHTML = '< i class = "bi bi-x-lg" > < / i > '; button.onclick = callback; return button;
}
function renderBuildings() {
const root = document.getElementById('buildingsList'); root.replaceChildren();
buildings.forEach((item, index) => {
const row = document.createElement('div'); row.className = 'row g-2 mb-2';
const nameCol = document.createElement('div'); nameCol.className = 'col-md-5'; const name = field(item.name, 'Nom du bâtiment'); name.oninput = () => item.name = name.value; nameCol.append(name);
const descCol = document.createElement('div'); descCol.className = 'col-md-6'; const desc = field(item.description, 'Description'); desc.oninput = () => item.description = desc.value; descCol.append(desc);
const action = document.createElement('div'); action.className = 'col-md-1'; action.append(removeButton(() => { buildings.splice(index, 1); renderBuildings(); }));
row.append(nameCol, descCol, action); root.append(row);
});
}
function addBuilding() { buildings.push({name: '', description: ''}); renderBuildings(); }
function buildingOptions() { return buildings.filter(b => b.id & & b.name).map(b => ({value: b.id, label: b.name})); }
function renderZones() {
const root = document.getElementById('zonesList'); root.replaceChildren();
zones.forEach((item, index) => {
const row = document.createElement('div'); row.className = 'row g-2 mb-2';
const nameCol = document.createElement('div'); nameCol.className = 'col-md-6'; const name = field(item.name, 'Nom de la zone'); name.oninput = () => item.name = name.value; nameCol.append(name);
const buildingCol = document.createElement('div'); buildingCol.className = 'col-md-5'; const building = selectField(buildingOptions(), item.building_id, 'Choisir un bâtiment'); building.onchange = () => item.building_id = Number(building.value); buildingCol.append(building);
const action = document.createElement('div'); action.className = 'col-md-1'; action.append(removeButton(() => { zones.splice(index, 1); renderZones(); }));
row.append(nameCol, buildingCol, action); root.append(row);
});
}
function addZone() { zones.push({name: '', building_id: buildings[0]?.id || null}); renderZones(); }
function zoneOptions(buildingId) { return zones.filter(z => Number(z.building_id) === Number(buildingId) & & z.id).map(z => ({value: z.id, label: z.name})); }
function renderRooms() {
const root = document.getElementById('roomsList'); root.replaceChildren();
rooms.forEach((item, index) => {
const row = document.createElement('tr');
const nameCell = document.createElement('td'); const name = field(item.name, 'Nom'); name.oninput = () => item.name = name.value; nameCell.append(name);
const codeCell = document.createElement('td'); const code = field(item.code, 'Code'); code.oninput = () => item.code = code.value; codeCell.append(code);
const buildingCell = document.createElement('td'); const building = selectField(buildingOptions(), item.building_id); building.onchange = () => { item.building_id = Number(building.value); item.zone_id = null; renderRooms(); }; buildingCell.append(building);
const zoneCell = document.createElement('td'); const zone = selectField(zoneOptions(item.building_id), item.zone_id, 'Sans zone'); zone.onchange = () => item.zone_id = zone.value ? Number(zone.value) : null; zoneCell.append(zone);
const floorCell = document.createElement('td'); const floor = field(item.floor ?? 0, '0', 'number'); floor.style.width = '75px'; floor.oninput = () => item.floor = Number(floor.value); floorCell.append(floor);
const action = document.createElement('td'); action.append(removeButton(() => { rooms.splice(index, 1); renderRooms(); }));
row.append(nameCell, codeCell, buildingCell, zoneCell, floorCell, action); root.append(row);
});
}
function addRoom() { rooms.push({name: '', code: '', building_id: buildings[0]?.id || null, zone_id: null, floor: 0}); renderRooms(); }
2026-08-14 18:02:25 +02:00
2026-08-14 20:43:10 +02:00
function renderVacationPeriods() {
const root = document.getElementById('vacationPeriodsList'); root.replaceChildren();
vacationPeriods.forEach((period, periodIndex) => {
period.work_days = period.work_days || [];
const card = document.createElement('div'); card.className = 'card mb-3';
const body = document.createElement('div'); body.className = 'card-body';
const periodRow = document.createElement('div'); periodRow.className = 'row g-2 align-items-end mb-3';
const nameCol = document.createElement('div'); nameCol.className = 'col-md-5'; nameCol.append(labelFor('Nom de la période'));
const name = field(period.name, 'Ex. Vacances de la Toussaint'); name.oninput = () => period.name = name.value; nameCol.append(name);
const startCol = document.createElement('div'); startCol.className = 'col-md-3'; startCol.append(labelFor('Début'));
const start = field(period.start_date, '', 'date'); start.oninput = () => period.start_date = start.value; startCol.append(start);
const endCol = document.createElement('div'); endCol.className = 'col-md-3'; endCol.append(labelFor('Fin'));
const end = field(period.end_date, '', 'date'); end.oninput = () => period.end_date = end.value; endCol.append(end);
const removeCol = document.createElement('div'); removeCol.className = 'col-md-1'; removeCol.append(removeButton(() => { vacationPeriods.splice(periodIndex, 1); renderVacationPeriods(); }));
periodRow.append(nameCol, startCol, endCol, removeCol); body.append(periodRow);
const heading = document.createElement('div'); heading.className = 'd-flex justify-content-between align-items-center mb-2';
const text = document.createElement('strong'); text.textContent = 'Journées travaillées pendant cette période';
const add = document.createElement('button'); add.type = 'button'; add.className = 'btn btn-outline-success btn-sm'; add.innerHTML = '< i class = "bi bi-calendar-plus" > < / i > Ajouter une journée';
add.onclick = () => { period.work_days.push({work_date: '', start_time: '08:00', end_time: '16:00', lunch_start: '12:00', lunch_end: '12:45', notes: ''}); renderVacationPeriods(); };
heading.append(text, add); body.append(heading);
const responsive = document.createElement('div'); responsive.className = 'table-responsive';
const table = document.createElement('table'); table.className = 'table table-sm align-middle';
table.innerHTML = '< thead > < tr > < th > Date< / th > < th > Début< / th > < th > Fin< / th > < th > Pause début< / th > < th > Pause fin< / th > < th > Note< / th > < th > < / th > < / tr > < / thead > ';
const tbody = document.createElement('tbody');
period.work_days.forEach((day, dayIndex) => {
const row = document.createElement('tr');
const values = [
['work_date', 'date'], ['start_time', 'time'], ['end_time', 'time'],
['lunch_start', 'time'], ['lunch_end', 'time'], ['notes', 'text'],
];
values.forEach(([key, type]) => {
const cell = document.createElement('td'); const input = field(day[key], '', type);
if (key === 'notes') input.placeholder = 'Ex. remise en route';
input.oninput = () => day[key] = input.value; cell.append(input); row.append(cell);
});
const action = document.createElement('td'); action.append(removeButton(() => { period.work_days.splice(dayIndex, 1); renderVacationPeriods(); })); row.append(action); tbody.append(row);
});
if (!period.work_days.length) {
const row = document.createElement('tr'); const cell = document.createElement('td'); cell.colSpan = 7; cell.className = 'text-muted text-center'; cell.textContent = 'Aucune présence : toute la période sera non travaillée.'; row.append(cell); tbody.append(row);
}
table.append(tbody); responsive.append(table); body.append(responsive); card.append(body); root.append(card);
});
if (!vacationPeriods.length) {
const empty = document.createElement('p'); empty.className = 'text-muted'; empty.textContent = 'Ajoutez les périodes manuellement en attendant l’ import des dates officielles.'; root.append(empty);
}
}
function labelFor(text) { const label = document.createElement('label'); label.className = 'form-label small mb-1'; label.textContent = text; return label; }
function addVacationPeriod() { vacationPeriods.push({name: '', start_date: '', end_date: '', work_days: []}); renderVacationPeriods(); }
2026-08-14 18:02:25 +02:00
function showStep(step) {
2026-08-14 20:20:16 +02:00
displayStep = step;
document.querySelectorAll('.step-panel').forEach((panel, index) => panel.classList.toggle('d-none', index + 1 !== step));
for (let i = 1; i < = totalSteps; i++) {
const icon = document.querySelector(`#step${i}-indicator i`);
icon.className = i < step ? ' bi bi-check-circle-fill fs-4 text-success ' : ` bi bi- $ { i } -circle fs-4 $ { i = == step ? ' text-primary ' : ' text-secondary ' } ` ;
}
document.getElementById('prevBtn').classList.toggle('invisible', step === 1);
document.getElementById('nextBtn').classList.toggle('d-none', step === totalSteps);
document.getElementById('finishBtn').classList.toggle('d-none', step !== totalSteps);
2026-08-14 20:43:10 +02:00
if (step === 3) renderBuildings(); if (step === 4) renderZones(); if (step === 5) renderRooms(); if (step === 7) renderVacationPeriods();
2026-08-14 18:02:25 +02:00
}
2026-08-14 20:20:16 +02:00
async function saveStep(step, data) {
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 (!response.ok || !result.success) throw new Error(result.error || 'Erreur de sauvegarde');
return result;
}
async function nextStep() {
try {
let result;
2026-08-14 18:02:25 +02:00
if (displayStep === 1) {
2026-08-14 20:20:16 +02:00
const username = document.getElementById('adminUsername').value.trim(), password = document.getElementById('adminPassword').value;
if (username.length < 3 | | password . length < 12 ) throw new Error ( ' Le nom doit faire 3 caractères et le mot de passe 12 caractères minimum . ' ) ;
result = await saveStep(1, {username, password});
2026-08-14 18:02:25 +02:00
} else if (displayStep === 2) {
2026-08-14 20:20:16 +02:00
const data = {name: document.getElementById('collegeName').value.trim(), address: document.getElementById('collegeAddress').value.trim(), zip: document.getElementById('collegeZip').value.trim(), city: document.getElementById('collegeCity').value.trim()};
if (!data.name || !data.address || !/^\d{5}$/.test(data.zip) || !data.city) throw new Error('Complétez les informations du collège (code postal à 5 chiffres).');
result = await saveStep(2, data);
2026-08-14 18:02:25 +02:00
} else if (displayStep === 3) {
2026-08-14 20:20:16 +02:00
const valid = buildings.filter(b => b.name?.trim()); if (!valid.length) throw new Error('Ajoutez au moins un bâtiment.');
result = await saveStep(3, {buildings: valid}); buildings = result.buildings;
2026-08-14 18:02:25 +02:00
} else if (displayStep === 4) {
2026-08-14 20:20:16 +02:00
result = await saveStep(4, {zones: zones.filter(z => z.name?.trim())}); zones = result.zones;
2026-08-14 18:02:25 +02:00
} else if (displayStep === 5) {
2026-08-14 20:20:16 +02:00
result = await saveStep(5, {rooms: rooms.filter(r => r.name?.trim())}); rooms = result.rooms;
2026-08-14 18:02:25 +02:00
} else if (displayStep === 6) {
2026-08-14 20:20:16 +02:00
result = await saveStep(6, {});
2026-08-14 18:02:25 +02:00
} else if (displayStep === 7) {
2026-08-14 20:43:10 +02:00
result = await saveStep(7, {zone: document.getElementById('academiqueZone').value, annee: Number(document.getElementById('anneeScolaire').value), import_vacances: document.getElementById('importAutoVacances').checked, import_feries: document.getElementById('importAutoFeries').checked, vacation_periods: vacationPeriods});
2026-08-14 18:02:25 +02:00
} else {
2026-08-14 20:20:16 +02:00
result = await saveStep(displayStep, {skipped: displayStep === 8 || displayStep === 9});
2026-08-14 18:02:25 +02:00
}
2026-08-14 20:20:16 +02:00
showStep(Math.min(displayStep + 1, totalSteps));
} catch (error) { alert(error.message); }
2026-08-14 18:02:25 +02:00
}
2026-08-14 20:20:16 +02:00
function previousStep() { if (displayStep > 1) showStep(displayStep - 1); }
async function finishSetup() {
try {
await saveStep(10, {});
const response = await fetch('/setup-wizard/api/complete', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
const result = await response.json(); if (!response.ok || !result.success) throw new Error(result.error || 'Finalisation impossible');
window.location.assign('/auth/login');
} catch (error) { alert(error.message); }
2026-08-14 18:02:25 +02:00
}
2026-08-14 20:43:10 +02:00
renderBuildings(); renderZones(); renderRooms(); renderVacationPeriods(); showStep(displayStep);
2026-08-14 18:02:25 +02:00
< / script >
2026-08-14 20:20:16 +02:00
{% endblock %}