gmao/app_new/templates/setup_wizard/index.html
root b051930431
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Gère les présences pendant les vacances scolaires
2026-08-14 18:43:10 +00:00

275 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Configuration initiale — GMAO{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<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>
</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>
<div id="step1" class="step-panel">
<h5>Compte administrateur</h5>
<div class="mb-3"><label class="form-label" for="adminUsername">Nom dutilisateur</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é à lapplication. 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 %}
</div>
</div>
<div id="step7" class="step-panel d-none">
<h5>Calendrier</h5>
<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>
<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>
<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 limporteur académique sera disponible</label></div>
<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 lorsquun importeur sera configuré</label></div>
<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>
</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 linstallation ; tant quelle 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 linstallation ; tant quelle 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 linstallation.</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>
</div>
</div>
</div>
<script>
const totalSteps = 10;
let displayStep = Math.min(Math.max(Number({{ progress.step|default(0) }}) + 1, 1), totalSteps);
const setupData = {{ progress.data|tojson }};
let buildings = setupData.buildings || [];
let zones = setupData.zones || [];
let rooms = setupData.rooms || [];
let vacationPeriods = setupData.vacation_periods || [];
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(); }
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 limport 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(); }
function showStep(step) {
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);
if (step === 3) renderBuildings(); if (step === 4) renderZones(); if (step === 5) renderRooms(); if (step === 7) renderVacationPeriods();
}
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;
if (displayStep === 1) {
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});
} else if (displayStep === 2) {
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);
} else if (displayStep === 3) {
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;
} else if (displayStep === 4) {
result = await saveStep(4, {zones: zones.filter(z => z.name?.trim())}); zones = result.zones;
} else if (displayStep === 5) {
result = await saveStep(5, {rooms: rooms.filter(r => r.name?.trim())}); rooms = result.rooms;
} else if (displayStep === 6) {
result = await saveStep(6, {});
} else if (displayStep === 7) {
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});
} else {
result = await saveStep(displayStep, {skipped: displayStep === 8 || displayStep === 9});
}
showStep(Math.min(displayStep + 1, totalSteps));
} catch (error) { alert(error.message); }
}
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); }
}
renderBuildings(); renderZones(); renderRooms(); renderVacationPeriods(); showStep(displayStep);
</script>
{% endblock %}