275 lines
21 KiB
HTML
275 lines
21 KiB
HTML
{% 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 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 %}
|
||
</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 l’importeur 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 lorsqu’un 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 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>
|
||
</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 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(); }
|
||
|
||
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 %}
|