/** * family-calendar.js (Version Multi-tenant 100% Purifiée) * 100% Dynamique - Zero nom hardcodé. */ // ============================================================================ // 1. VARIABLES GLOBALES & LOGIQUE DE CONFIGURATION (SETTINGS) // ============================================================================ let calGlobalData = null; let currentSelectedMemberId = null; let localCareModes = []; async function openCalendarSettings() { try { const res = await pachaFetch( "/modules/family-calendar/includes/api/calendar-settings.php?action=get_all", ); if (!res.success) throw new Error(res.error); calGlobalData = res.data; const zoneInput = document.getElementById("setZoneScolaire"); if (zoneInput) zoneInput.value = calGlobalData.foyer.zone_scolaire; localCareModes = calGlobalData.foyer.care_modes || []; renderCareModeTags(); const selectMember = document.getElementById("selectCalMember"); if (selectMember) { selectMember.innerHTML = calGlobalData.people .map((p) => { let roleDisplay = tr("fc_role_unknown") || "Inconnu"; if (p.role === "parent") roleDisplay = "👨‍👩‍👦 " + (tr("fc_role_adult") || "Adulte"); else if (p.role === "child" || p.role === "enfant") roleDisplay = "👶 " + (tr("fc_role_child") || "Enfant"); else if (p.role === "helper") roleDisplay = "💼 " + (tr("fc_role_helper") || "Intervenant"); return ``; }) .join(""); } if (typeof populateCalendarSettings === "function") { populateCalendarSettings(calGlobalData.calendar_settings); } switchCalendarTab("foyer"); const modalSettings = document.getElementById("modalCalendarSettings"); if (modalSettings) { modalSettings.classList.add("open"); document.body.classList.add("no-scroll"); } } catch (err) { alert((tr("fc_error") || "Erreur : ") + err.message); } } function closeCalendarSettings() { document.getElementById("modalCalendarSettings").classList.remove("open"); document.body.classList.remove("no-scroll"); } function switchCalendarTab(tabId) { // 1. On réinitialise l'affichage document .querySelectorAll(".bs-tab-btn") .forEach((btn) => btn.classList.remove("active")); document .querySelectorAll(".cal-settings-pane") .forEach((pane) => (pane.style.display = "none")); // 2. On active l'onglet cliqué document.getElementById(`tab-btn-${tabId}`).classList.add("active"); document.getElementById(`cal-pane-${tabId}`).style.display = "block"; // 3. Actions spécifiques if (tabId === "foyer") { loadLeaveCatalog(); } else if (tabId === "membres") { // On charge la vue du membre sélectionné par défaut dans la liste if (document.getElementById("selectCalMember").value) { loadMemberConfigView(); } } } function renderCareModeTags() { const container = document.getElementById("careModesContainer"); if (!container) return; // 🛑 LA BARRIÈRE DE SÉCURITÉ EST ICI container.innerHTML = localCareModes .map( (mode, i) => ` ${mode} × `, ) .join(""); } // Ajoutons la même sécurité pour l'ajout au cas où : function addCareModeTag() { const input = document.getElementById("inputNewCareMode"); if (!input) return; // 🛑 SÉCURITÉ const val = input.value.trim(); if (val && !localCareModes.includes(val)) { localCareModes.push(val); renderCareModeTags(); input.value = ""; } } function removeCareModeTag(index) { localCareModes.splice(index, 1); renderCareModeTags(); } async function submitCalFoyer(e) { e.preventDefault(); try { const formData = new FormData(); formData.append("action", "save_foyer"); formData.append( "zone_scolaire", document.getElementById("setZoneScolaire").value, ); formData.append("care_modes", JSON.stringify(localCareModes)); const res = await pachaFetch( "/modules/family-calendar/includes/api/calendar-settings.php", { method: "POST", body: formData }, ); if (!res.success) throw new Error(res.error); if (window.showToast) showToast( tr("fc_settings_updated") || "Configuration enregistrée !", "success", ); else alert(tr("fc_settings_updated") || "Configuration enregistrée !"); setTimeout(() => window.location.reload(), 800); } catch (err) { alert("Erreur : " + err.message); } } window.submitChildCareModes = async function () { if (!window.currentSelectedMemberId) return; const checkboxes = document.querySelectorAll(".js-care-mode-cb:checked"); const selectedModes = Array.from(checkboxes).map((cb) => cb.value); try { const formData = new FormData(); formData.append("action", "save_child_care_modes"); formData.append("person_id", window.currentSelectedMemberId); formData.append("care_modes", JSON.stringify(selectedModes)); const res = await pachaFetch( "/modules/family-calendar/includes/api/calendar-settings.php", { method: "POST", body: formData }, ); if (!res.success) throw new Error(res.error); if (window.showToast) showToast(tr("fc_child_saved") || "Sauvegardé !", "success"); else alert(tr("fc_child_saved") || "Sauvegardé !"); const currentPerson = calGlobalData.people.find( (k) => parseInt(k.id) === window.currentSelectedMemberId, ); if (currentPerson) { currentPerson.care_modes = JSON.stringify(selectedModes); } } catch (err) { alert("Erreur: " + err.message); } }; // ========================================== // NOUVEAU CATALOGUE GLOBAL (FOYER) // ========================================== let globalLeaveCatalog = []; async function loadLeaveCatalog() { try { const res = await pachaFetch( "/modules/family-calendar/includes/api/calendar-settings.php?action=get_leave_types", ); globalLeaveCatalog = res.data || []; renderLeaveCatalog(globalLeaveCatalog); } catch (err) { console.error("Erreur chargement catalogue", err); } } function renderLeaveCatalog(types) { const container = document.getElementById("leaveTypesContainer"); if (!container) return; container.innerHTML = ""; if (types.length === 0) { container.innerHTML = `

Aucun congé configuré.

`; return; } types.forEach((lt) => { container.innerHTML += `
${lt.label} ${lt.code}
`; }); } function editLeaveType(code, label) { document.getElementById("leaveTypeFormTitle").innerText = "✏️ Modifier : " + label; document.getElementById("lt-mode").value = "edit"; const codeInput = document.getElementById("lt-code"); codeInput.value = code; codeInput.readOnly = true; codeInput.style.backgroundColor = "var(--bg-subtle)"; codeInput.style.opacity = "0.6"; codeInput.style.cursor = "not-allowed"; document.getElementById("lt-code-note").innerText = "Non modifiable"; document.getElementById("lt-label").value = label; } function resetLeaveTypeForm() { document.getElementById("leaveTypeFormTitle").innerText = "+ Ajouter"; document.getElementById("lt-mode").value = "add"; const codeInput = document.getElementById("lt-code"); codeInput.value = ""; codeInput.readOnly = false; codeInput.style.backgroundColor = ""; codeInput.style.opacity = "1"; codeInput.style.cursor = "text"; document.getElementById("lt-code-note").innerText = "Irréversible"; document.getElementById("lt-label").value = ""; } // NOUVELLE FONCTION DE SUPPRESSION async function deleteLeaveType(code, label) { if ( !confirm( `Supprimer définitivement le congé "${label}" du catalogue ? Cela le retirera également des membres qui l'utilisent.`, ) ) return; const fd = new FormData(); fd.append("action", "delete_leave_type"); fd.append("code", code); try { const res = await pachaFetch( "/modules/family-calendar/includes/api/calendar-settings.php", { method: "POST", body: fd }, ); if (!res.success) throw new Error(res.error); if (window.showToast) showToast("Congé supprimé avec succès !", "success"); resetLeaveTypeForm(); loadLeaveCatalog(); // On recharge l'onglet membre au cas où le membre affiché utilisait ce congé if (document.getElementById("selectCalMember").value) { loadMemberConfigView(); } } catch (err) { alert("Erreur de suppression : " + err.message); } } async function saveLeaveType() { const code = document.getElementById("lt-code").value.toUpperCase(); const label = document.getElementById("lt-label").value; if (!code || !label) return window.showToast ? showToast("Code et Label requis", "error") : alert("Code et Label requis"); const fd = new FormData(); fd.append("action", "save_leave_type"); fd.append("mode", document.getElementById("lt-mode").value); fd.append("code", code); fd.append("label", label); try { const res = await pachaFetch( "/modules/family-calendar/includes/api/calendar-settings.php", { method: "POST", body: fd }, ); if (!res.success) throw new Error(res.error); if (window.showToast) showToast("Catalogue mis à jour !", "success"); resetLeaveTypeForm(); loadLeaveCatalog(); } catch (err) { alert("Erreur d'enregistrement : " + err.message); } } // ========================================== // MODALE SETTINGS : AFFECTATION (MEMBRES) // ========================================== // Quand on choisit un membre dans la liste déroulante async function loadMemberConfigView() { const select = document.getElementById("selectCalMember"); const zone = document.getElementById("memberConfigZone"); if (!select || !select.value || !zone) return; const personId = parseInt(select.value); window.currentSelectedMemberId = personId; const role = ( select.options[select.selectedIndex].dataset.role || "" ).toLowerCase(); zone.innerHTML = '

Chargement...

'; // === CAS 1 : C'EST UN ENFANT (On gère les modes de garde) === if (role === "child" || role === "enfant") { const currentPerson = calGlobalData.people.find( (k) => parseInt(k.id) === personId, ); let savedModes = []; try { if (currentPerson.modes && Array.isArray(currentPerson.modes)) savedModes = currentPerson.modes; else if (typeof currentPerson.care_modes === "string") savedModes = JSON.parse(currentPerson.care_modes); } catch (e) {} const activeModesInFoyer = calGlobalData.foyer.care_modes || []; const modesHtml = activeModesInFoyer .map( (m) => ` `, ) .join(""); zone.innerHTML = `
${tr("fc_care_modes_title") || "Modes de Garde"}

Quels modes de garde s'appliquent à cet enfant ?

${modesHtml || `Aucun mode de garde configuré dans le foyer.`}
`; } // === CAS 2 : C'EST UN ADULTE (On gère les congés) === else { try { const res = await pachaFetch( `/modules/family-calendar/includes/api/calendar-settings.php?action=get_person_leaves&person_id=${personId}`, ); const memberLeaves = res.data || []; renderMemberLeavesView(personId, memberLeaves); } catch (err) { zone.innerHTML = '

Erreur de chargement.

'; } } } // Affichage des congés du membre et du formulaire d'ajout function renderMemberLeavesView(personId, memberLeaves) { const zone = document.getElementById("memberConfigZone"); let html = `
Congés attribués
`; if (memberLeaves.length === 0) { html += `

Aucun congé attribué à ce membre.

`; } else { html += `
`; memberLeaves.forEach((ml) => { // On extrait le mois de la date anniversaire (ex: "2000-06-01" -> 6) const moisRenouv = ml.anniversary_date ? parseInt(ml.anniversary_date.split("-")[1]) : 1; html += `
${ml.leave_type} (Quota: ${ml.allowance}j - Renouv: Mois ${moisRenouv})
`; }); html += `
`; } // Ajout du selecteur de mois dans l'attribution html += `
+ Attribuer un congé
`; zone.innerHTML = html; } async function addMemberLeave(personId) { const leaveCode = document.getElementById("new-member-leave-type").value; const allowance = document.getElementById("new-member-leave-allowance").value; const resetMonth = document.getElementById("new-member-leave-reset").value; const method = document.getElementById("new-member-leave-method").value; // <-- Ajout fd.append("method", method); if (!leaveCode) return showToast("Veuillez sélectionner un type", "error"); const fd = new FormData(); fd.append("action", "add_person_leave"); fd.append("person_id", personId); fd.append("leave_type", leaveCode); fd.append("allowance", allowance); fd.append("reset_month", resetMonth); try { const res = await pachaFetch( "/modules/family-calendar/includes/api/calendar-settings.php", { method: "POST", body: fd }, ); if (!res.success) throw new Error(res.error); if (window.showToast) showToast("Congé attribué avec succès !", "success"); loadMemberConfigView(); } catch (err) { alert("Erreur: " + err.message); } } // Supprimer un congé d'un membre async function deleteMemberLeave(leaveId, personId) { if (!confirm(tr("confirm_delete") || "Retirer ce congé pour ce membre ?")) return; const fd = new FormData(); fd.append("action", "delete_person_leave"); fd.append("id", leaveId); try { const res = await pachaFetch( "/modules/family-calendar/includes/api/calendar-settings.php", { method: "POST", body: fd }, ); if (!res.success) throw new Error(res.error); if (window.showToast) showToast("Congé retiré !", "success"); loadMemberConfigView(); } catch (err) { alert("Erreur: " + err.message); } } // ============================================================================ // 2. MOTEUR PRINCIPAL DU CALENDRIER (DOM CONTENT LOADED) // ============================================================================ document.addEventListener("DOMContentLoaded", () => { class FamilyCalendar { constructor() { this.planningBody = document.getElementById("planningBody"); this.selectionMenu = document.getElementById("selectionMenu"); this.schoolHolidaysTableBody = document.querySelector( "#schoolHolidaysTable tbody", ); this.monthCalendar = document.getElementById("fc-month-calendar"); this.monthSelectionMenu = document.getElementById( "fc-month-selectionMenu", ); if ( this.selectionMenu && this.selectionMenu.parentElement !== document.body ) document.body.appendChild(this.selectionMenu); if ( this.monthSelectionMenu && this.monthSelectionMenu.parentElement !== document.body ) document.body.appendChild(this.monthSelectionMenu); this.currentMonth = new Date(); this.currentMonth.setDate(1); this.viewMode = "1month"; this.currentSchoolYearStart = null; this.modalSelectedYear = null; this.isSelecting = false; this.selectedCells = []; this.monthSelectedCells = []; this._currentBulkInfo = null; this.dbEvents = []; this.fixedEvents = []; this.events = []; this.leaves = []; this.weeks = []; this.parents = []; this.kids = []; this.helpers = []; this.careModes = []; this.leaveMatrix = {}; this.monthlyLeaveBalances = {}; if (!this.planningBody) return; this.init(); } getLocalIsoDate(dateObj) { const y = dateObj.getFullYear(); const m = String(dateObj.getMonth() + 1).padStart(2, "0"); const d = String(dateObj.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } async init() { this.setupEventListeners(); const now = new Date(); this.currentSchoolYearStart = now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1; this.modalSelectedYear = this.currentSchoolYearStart; this.setupModalUI(); this.initSmartSelectors(); try { const res = await pachaFetch( "/modules/family-calendar/includes/api/calendar-settings.php?action=get_all", ); if (res && res.success && res.data) { window.calGlobalData = res.data; this.parents = (res.data.people || []).filter( (p) => p.role === "parent", ); this.kids = (res.data.people || []).filter((p) => ["child", "enfant"].includes(p.role), ); this.helpers = (res.data.people || []).filter( (p) => p.role === "helper", ); this.careModes = res.data.foyer.care_modes || []; this.leaveMatrix = res.data.leaves || {}; const settings = res.data.calendar_settings || {}; if (settings.calendar_default_view) { this.viewMode = settings.calendar_default_view; document .querySelectorAll(".fc-view-button") .forEach((b) => b.classList.remove("fc-view-button--active")); const activeBtn = document.querySelector( `.fc-view-button[data-view="${this.viewMode}"]`, ); if (activeBtn) activeBtn.classList.add("fc-view-button--active"); } } } catch (e) { console.warn("Erreur chargement de l'annuaire familial:", e); } await this.refreshAllData(); this.updateSchoolYearLabel(); setTimeout(() => this.scrollToCurrentMonth(), 100); } setupModalUI() { const headerTitle = document.querySelector( "#modalHolidays .pf-modal-title", ); if (headerTitle && !document.getElementById("holidayYearSelect")) { let options = ""; const currentY = new Date().getFullYear(); for (let y = currentY - 2; y <= currentY + 3; y++) { options += ``; } const zoneText = window.calGlobalData?.foyer?.zone_scolaire && window.calGlobalData.foyer.zone_scolaire !== "Autre" ? `(Zone ${window.calGlobalData.foyer.zone_scolaire})` : ""; headerTitle.innerHTML = `🏖️ ${tr("fc_modal_holidays_title") || "Vacances"} ${zoneText} `; document .getElementById("holidayYearSelect") .addEventListener("change", (e) => { this.modalSelectedYear = parseInt(e.target.value); this.renderModalHolidays(); }); } } initSmartSelectors() { const selectMonth = document.getElementById("fc-select-month"); const selectYear = document.getElementById("fc-select-year"); if (!selectMonth || !selectYear) return; const lang = window.appLang || "fr-FR"; for (let i = 0; i < 12; i++) { selectMonth.add( new Option( new Intl.DateTimeFormat(lang, { month: "long" }).format( new Date(2000, i, 1), ), i, ), ); } const currentY = new Date().getFullYear(); for (let y = currentY - 2; y <= currentY + 5; y++) selectYear.add(new Option(y, y)); const handleChange = () => { this.currentMonth = new Date( parseInt(selectYear.value), parseInt(selectMonth.value), 1, ); this.renderMonthCalendar(); }; selectMonth.addEventListener("change", handleChange); selectYear.addEventListener("change", handleChange); } async refreshAllData() { try { const [ weeksData, eventsData, fixedEventsData, leavesData, snapshotsData, ] = await Promise.all([ this.fetchApi( `/modules/family-calendar/includes/api/get-calendar-weeks-scolaire.php?school_year_start=${this.currentSchoolYearStart}`, ), this.fetchApi("/modules/family-calendar/includes/api/get-events.php"), this.fetchPublicHolidays(), this.fetchApi("/modules/family-calendar/includes/api/get-leaves.php"), this.fetchApi( "/modules/family-calendar/includes/api/get-leave-snapshots.php", ), ]); this.weeks = this.processWeeks(weeksData.weeks || []); this.dbEvents = (eventsData.events || []).map((e) => ({ ...e, duration: parseFloat(e.duration), })); this.fixedEvents = fixedEventsData; this.leaves = leavesData.leaves || []; this.leaveSnapshots = snapshotsData.snapshots || []; this.events = [...this.dbEvents, ...this.fixedEvents]; this.publicHolidayDates = new Set( this.fixedEvents .filter((e) => e.type === "PUBLIC_HOLIDAY") .map((e) => e.date), ); this.reprocessAndRender(); this.renderModalHolidays(); } catch (e) { console.error("Erreur chargement données:", e); } } async fetchPublicHolidays() { try { const res = await fetch( "https://calendrier.api.gouv.fr/jours-feries/metropole.json", ); const holidays = await res.json(); return Object.keys(holidays).map((date, idx) => ({ id: `ph-${idx}`, date: date, name: holidays[date], type: "PUBLIC_HOLIDAY", duration: 1, })); } catch (error) { return []; } } renderModalHolidays() {} async fetchAndSaveGovHolidays(yearStart) {} reprocessAndRender() { this.reprocessEvents(); this.calculateMonthlyBalances(); this.renderTable(); this.renderMonthCalendar(); } reprocessEvents() { const upperCareModes = this.careModes.map((m) => m.toUpperCase()); this.weeks.forEach((w) => { w.totals = {}; this.careModes.forEach((m) => (w.totals["mode_" + m] = 0)); this.kids.forEach((k) => (w.totals["sick_" + k.id] = 0)); this.helpers.forEach((h) => { w.totals["off_" + h.id] = 0; w.totals["extra_" + h.id] = 0; }); this.parents.forEach((p) => { const types = this.leaveMatrix[p.id] || []; types.forEach((t) => (w.totals[`leave_${p.id}_${t.type}`] = 0)); }); Object.values(w.dayFlags).forEach((f) => (f.events = [])); this.events.forEach((e) => { const d = new Date(e.date + "T00:00:00"); if (d >= w.dayDates.mon && d <= w.dayDates.fri) { const dayKey = Object.keys(w.dayDates).find( (k) => w.dayDates[k].getTime() === d.getTime(), ); if (dayKey) w.dayFlags[dayKey].events.push(e); const dur = parseFloat(e.duration) || 1; if (e.type === "CHILD_SICK") w.totals["sick_" + e.person_id] = (w.totals["sick_" + e.person_id] || 0) + dur; if (e.type === "HELPER_OFF") w.totals["off_" + e.person_id] = (w.totals["off_" + e.person_id] || 0) + dur; if (e.type === "HELPER_EXTRA") w.totals["extra_" + e.person_id] = (w.totals["extra_" + e.person_id] || 0) + dur; // Modes de garde dynamiques if (upperCareModes.includes(e.type)) { const originalMode = this.careModes.find((m) => m.toUpperCase() === e.type) || e.type; w.totals["mode_" + originalMode] = (w.totals["mode_" + originalMode] || 0) + dur; } } }); this.leaves.forEach((l) => { const d = new Date(l.leave_date + "T00:00:00"); if (d >= w.dayDates.mon && d <= w.dayDates.fri) { const dur = parseFloat(l.duration) || 1; w.totals[`leave_${l.person_id}_${l.leave_type}`] = (w.totals[`leave_${l.person_id}_${l.leave_type}`] || 0) + dur; } }); let workingDays = 0; Object.values(w.dayDates).forEach((d) => { if (!this.publicHolidayDates.has(this.getLocalIsoDate(d))) workingDays++; }); let helperAbsences = 0; this.helpers.forEach( (h) => (helperAbsences += (w.totals["off_" + h.id] || 0) + (w.totals["extra_" + h.id] || 0)), ); if (this.kids.length > 0) { w.totals.presenceKid = Math.max( 0, workingDays - (helperAbsences + (w.totals["sick_" + this.kids[0].id] || 0)), ); } }); } calculateMonthlyBalances() { const balances = {}; this.parents.forEach((p) => (balances[p.id] = {})); const ymSet = new Set(); this.weeks.forEach((w) => ymSet.add(w.monthKey)); const ymList = Array.from(ymSet).sort(); const usageByMonth = {}; this.leaves.forEach((l) => { const pid = l.person_id, type = l.leave_type, ym = l.leave_date.substring(0, 7); if (!usageByMonth[pid]) usageByMonth[pid] = {}; if (!usageByMonth[pid][type]) usageByMonth[pid][type] = {}; usageByMonth[pid][type][ym] = (usageByMonth[pid][type][ym] || 0) + parseFloat(l.duration); }); this.parents.forEach((parent) => { const pid = parent.id; const matrix = this.leaveMatrix[pid] || []; matrix.forEach((conf) => { const type = conf.type; balances[pid][type] = {}; ymList.forEach((ym) => { const [currYear, currMonth] = ym.split("-").map(Number); let cycleStartStr = "", initialBalance = parseFloat(conf.allowance || 0); if (conf.date) { const parts = conf.date.split("-"); if (parts.length >= 2) { const monthRenouvellement = parseInt(parts[1]); const dayRenouvellement = parts.length === 3 ? parseInt(parts[2]) : 1; const isPastAnniversary = currMonth > monthRenouvellement || (currMonth === monthRenouvellement && 1 >= dayRenouvellement); const refYear = isPastAnniversary ? currYear : currYear - 1; cycleStartStr = `${refYear}-${String(monthRenouvellement).padStart(2, "0")}`; } } else { cycleStartStr = `${currYear}-01`; } let usedBeforeCurrentMonth = 0; Object.keys(usageByMonth[pid]?.[type] || {}).forEach((usedYm) => { if (usedYm >= cycleStartStr && usedYm < ym) usedBeforeCurrentMonth += usageByMonth[pid][type][usedYm]; }); balances[pid][type][ym] = { availableAtMonthStart: Math.max( 0, initialBalance - usedBeforeCurrentMonth, ), usedInMonth: usageByMonth[pid]?.[type]?.[ym] || 0, }; }); }); }); this.monthlyLeaveBalances = balances; } renderTable() { if (!this.planningBody) return; this.planningBody.innerHTML = ""; const monthSpans = this.weeks.reduce((acc, w) => { acc[w.monthKey] = (acc[w.monthKey] || 0) + 1; return acc; }, {}); const processedMonths = {}; const processedLeavesCols = {}; const fmt = (n) => n > 0 ? (Number.isInteger(n) ? n : n.toFixed(1)) : ""; const upperCareModes = this.careModes.map((m) => m.toUpperCase()); this.weeks.forEach((w, idx) => { const tr = document.createElement("tr"); tr.setAttribute("data-month", w.monthKey); if (idx === 0 || this.weeks[idx - 1].monthKey !== w.monthKey) tr.classList.add("fc-month-first-week-row"); if ( idx === this.weeks.length - 1 || this.weeks[idx + 1].monthKey !== w.monthKey ) tr.classList.add("fc-month-last-week-row"); if (!processedMonths[w.monthKey]) { processedMonths[w.monthKey] = true; const td = document.createElement("td"); td.className = "col-month col-sticky-mois"; td.innerHTML = `${w.monthName}`; td.rowSpan = monthSpans[w.monthKey]; tr.appendChild(td); } const tdW = document.createElement("td"); tdW.className = "col-month col-sticky-sem"; tdW.textContent = w.weekLabel; tr.appendChild(tdW); ["mon", "tue", "wed", "thu", "fri"].forEach((d) => { const td = document.createElement("td"); const dateObj = w.dayDates[d]; const iso = this.getLocalIsoDate(dateObj); td.dataset.date = iso; td.className = "col-day"; w.dayFlags[d].events.forEach((evt) => { if (evt.type === "PUBLIC_HOLIDAY") td.classList.add("fc-day--public-holiday"); if (evt.type === "VACANCES_SCOLAIRES") td.classList.add("fc-day--school-holiday"); if (evt.type === "HELPER_OFF") td.classList.add("fc-day--off-carole"); if (evt.type === "HELPER_EXTRA") td.classList.add("fc-day--extra-off-carole"); if (upperCareModes.includes(evt.type)) td.classList.add("fc-day--has-guard"); }); let content = `
${String(dateObj.getDate()).padStart(2, "0")}`; let iconsHtml = `
`; w.dayFlags[d].events.forEach((evt) => { if (upperCareModes.includes(evt.type)) { const modeName = evt.type.toLowerCase(); if (modeName === "avis") iconsHtml += ``; else if (modeName === "centre") iconsHtml += `🏫`; else iconsHtml += `${modeName.substring(0, 3)}`; } }); content += iconsHtml + `
`; let sickHtml = `
`; w.dayFlags[d].events.forEach((evt) => { if (evt.type === "CHILD_SICK") { const k = this.kids.find( (x) => parseInt(x.id) === parseInt(evt.person_id), ); if (k) { sickHtml += `${k.name}🤒`; } } }); content += sickHtml + `
`; const dayLeaves = this.leaves.filter((l) => l.leave_date === iso); if (dayLeaves.length) { let html = `
`; this.parents.forEach((person) => { if ( dayLeaves.some( (l) => parseInt(l.person_id) === parseInt(person.id), ) ) { html += `${person.name.charAt(0).toUpperCase()}`; } }); content += html + `
`; } td.innerHTML = content + `
`; tr.appendChild(td); }); this.careModes.forEach((mode) => { const td = document.createElement("td"); td.className = "col-total"; td.textContent = fmt(w.totals["mode_" + mode] || 0); tr.appendChild(td); }); this.kids.forEach((kid) => { const td = document.createElement("td"); td.className = "col-total"; td.textContent = fmt(w.totals["sick_" + kid.id] || 0); tr.appendChild(td); }); if (!processedLeavesCols[w.monthKey]) { processedLeavesCols[w.monthKey] = true; this.parents.forEach((parent, index) => { const cssPrefix = index % 2 === 0 ? "col-alex" : "col-laia"; (this.leaveMatrix[parent.id] || []).forEach((conf) => { const info = this.monthlyLeaveBalances[parent.id]?.[conf.type]?.[w.monthKey]; tr.innerHTML += `${info ? fmt(info.availableAtMonthStart) : "-"}`; tr.innerHTML += `${info ? fmt(info.usedInMonth) : ""}`; }); }); } this.planningBody.appendChild(tr); }); } generateMonthHTML(year, month) { let html = ``; ["L", "M", "M", "J", "V"].forEach((d) => (html += ``)); html += ``; const daysInMonth = new Date(year, month + 1, 0).getDate(); let currentRenderedCols = 0, startDay = (new Date(year, month, 1).getDay() + 6) % 7; const upperCareModes = this.careModes.map((m) => m.toUpperCase()); if (startDay < 5) { for (let i = 0; i < startDay; i++) { html += ``; currentRenderedCols++; } } for (let d = 1; d <= daysInMonth; d++) { const dateObj = new Date(year, month, d), dayOfWeek = dateObj.getDay(); if (dayOfWeek === 0 || dayOfWeek === 6) continue; if (currentRenderedCols === 5) { html += ``; currentRenderedCols = 0; } const iso = this.getLocalIsoDate(dateObj), todayIso = this.getLocalIsoDate(new Date()); let cls = "fc-month-day" + (iso === todayIso ? " fc-day--today" : ""); const dayEvts = this.events.filter((e) => e.date === iso); if (dayEvts.some((e) => e.type === "VACANCES_SCOLAIRES")) cls += " fc-day--school-holiday"; if (dayEvts.some((e) => e.type === "PUBLIC_HOLIDAY")) cls += " fc-day--public-holiday"; if (dayEvts.some((e) => e.type === "HELPER_OFF")) cls += " fc-day--off-carole"; if (dayEvts.some((e) => e.type === "HELPER_EXTRA")) cls += " fc-day--extra-off-carole"; if (dayEvts.some((e) => upperCareModes.includes(e.type))) cls += " fc-day--has-guard"; let content = `
${d}`; let iconsHtml = `
`; dayEvts.forEach((evt) => { if (upperCareModes.includes(evt.type)) { const modeName = evt.type.toLowerCase(); if (modeName === "avis") iconsHtml += ``; else if (modeName === "centre") iconsHtml += `🏫`; else iconsHtml += `${modeName.substring(0, 3)}`; } }); content += iconsHtml + `
`; let sickHtml = `
`; dayEvts.forEach((evt) => { if (evt.type === "CHILD_SICK") { const k = this.kids.find( (x) => parseInt(x.id) === parseInt(evt.person_id), ); if (k) { sickHtml += `${k.name} 🤒`; } } }); content += sickHtml + `
`; const dayLeaves = this.leaves.filter((l) => l.leave_date === iso); if (dayLeaves.length) { content += `
`; this.parents.forEach((parent) => { if ( dayLeaves.some( (l) => parseInt(l.person_id) === parseInt(parent.id), ) ) { content += `${parent.name.charAt(0).toUpperCase()} `; } }); content += `
`; } html += `
`; currentRenderedCols++; } while (currentRenderedCols < 5 && currentRenderedCols > 0) { html += ``; currentRenderedCols++; } return html + `
${d}
${content}
`; } generateMonthSummaryHTML(year, month) { const stats = { off: 0, extra: 0, sick: 0, presence: 0 }; const daysInMonth = new Date(year, month + 1, 0).getDate(); for (let d = 1; d <= daysInMonth; d++) { const dateObj = new Date(year, month, d); const dayOfWeek = dateObj.getDay(); if (dayOfWeek === 0 || dayOfWeek === 6) continue; const iso = this.getLocalIsoDate(dateObj); const dayEvents = this.events.filter((e) => e.date === iso); dayEvents.forEach((e) => { const dur = parseFloat(e.duration) || 1; if (e.type === "HELPER_OFF") stats.off += dur; if (e.type === "HELPER_EXTRA") stats.extra += dur; if (e.type === "CHILD_SICK") stats.sick += dur; }); if ( !this.publicHolidayDates.has(iso) && !dayEvents.some((e) => e.type === "VACANCES_SCOLAIRES") ) { let dayAbsence = 0; dayEvents.forEach((e) => { if (["HELPER_OFF", "HELPER_EXTRA", "CHILD_SICK"].includes(e.type)) { dayAbsence += parseFloat(e.duration) || 1; } }); stats.presence += Math.max(0, 1 - dayAbsence); } } return `
Off ${parseFloat(stats.off.toFixed(1))} j
Extra ${parseFloat(stats.extra.toFixed(1))} j
Maladie ${parseFloat(stats.sick.toFixed(1))} j
Présence ${parseFloat(stats.presence.toFixed(1))} j
`; } scrollToCurrentMonth() { const wrapper = document.getElementById("planningTable-wrapper"); const targetRow = document.querySelector( `#planningTable tbody tr[data-month="${this.getLocalIsoDate(new Date()).slice(0, 7)}"]`, ); if (wrapper && targetRow) setTimeout( () => wrapper.scrollTo({ top: Math.max(0, targetRow.offsetTop - 50), behavior: "smooth", }), 50, ); } renderMonthCalendar() { if (!this.monthCalendar) return; const y = this.currentMonth.getFullYear(), m = this.currentMonth.getMonth(); const selectMonth = document.getElementById("fc-select-month"); const selectYear = document.getElementById("fc-select-year"); if (selectMonth && selectYear) { selectMonth.value = m; selectYear.value = y; if (this.viewMode === "3months") this.renderThreeMonthsView(); else if (this.viewMode === "2months") this.renderTwoMonthsView(); else this.monthCalendar.innerHTML = `
${this.generateMonthHTML(y, m)}${this.generateMonthSummaryHTML(y, m)}
`; } this.renderMonthBalances(); } renderTwoMonthsView() { const y = this.currentMonth.getFullYear(), m = this.currentMonth.getMonth(); const nextDate = new Date(y, m + 1, 1); const lang = window.appLang || "fr-FR"; this.monthCalendar.innerHTML = `
${new Intl.DateTimeFormat(lang, { month: "long" }).format(this.currentMonth)}
${this.generateMonthHTML(y, m)}${this.generateMonthSummaryHTML(y, m)}
${new Intl.DateTimeFormat(lang, { month: "long" }).format(nextDate)}
${this.generateMonthHTML(nextDate.getFullYear(), nextDate.getMonth())}${this.generateMonthSummaryHTML(nextDate.getFullYear(), nextDate.getMonth())}
`; } renderThreeMonthsView() { const y = this.currentMonth.getFullYear(), m = this.currentMonth.getMonth(); const [d1, d2, d3] = [ this.currentMonth, new Date(y, m + 1, 1), new Date(y, m + 2, 1), ]; let html = `
`; const lang = window.appLang || "fr-FR"; [d1, d2, d3].forEach( (d) => (html += `
${new Intl.DateTimeFormat(lang, { month: "long" }).format(d)}
${this.generateMonthHTML(d.getFullYear(), d.getMonth())}${this.generateMonthSummaryHTML(d.getFullYear(), d.getMonth())}
`), ); this.monthCalendar.innerHTML = html + `
`; } renderMonthBalances() { const container = document.getElementById("fc-month-balances"); if (!container) return; const monthsToDisplay = [], y = this.currentMonth.getFullYear(), m = this.currentMonth.getMonth(); let numMonths = this.viewMode === "2months" ? 2 : this.viewMode === "3months" ? 3 : 1; for (let i = 0; i < numMonths; i++) monthsToDisplay.push(`${y}-${String(m + i + 1).padStart(2, "0")}`); container.style.display = "flex"; container.innerHTML = this.parents .map((person) => { let cards = `
${person.name.toUpperCase()}
`; const types = this.leaveMatrix[person.id] || []; types.forEach((conf) => { const type = conf.type; const startBal = this.monthlyLeaveBalances[person.id]?.[type]?.[monthsToDisplay[0]] ?.availableAtMonthStart || 0; let totalUsed = 0; monthsToDisplay.forEach( (ym) => (totalUsed += this.monthlyLeaveBalances[person.id]?.[type]?.[ym] ?.usedInMonth || 0), ); const endBal = Math.max(0, startBal - totalUsed); const fmt = (n) => n > 0 ? (Number.isInteger(n) ? n : n.toFixed(1)) : "0"; let alertHtml = ""; const cMonth = parseInt(monthsToDisplay[0].split("-")[1]); // On récupère le mois de renouvellement depuis la date (ex: "2000-06-01" -> 6) if (endBal > 0 && conf.date) { const resetMonth = parseInt(conf.date.split("-")[1]); // On alerte le mois même, ou le mois juste avant ! const alertMonth = resetMonth - 1 === 0 ? 12 : resetMonth - 1; if (cMonth === alertMonth || cMonth === resetMonth) { alertHtml = `
🔥
`; } } cards += `
${type}${fmt(endBal)}${totalUsed > 0 ? `-${fmt(totalUsed)}` : ""}${alertHtml}
`; }); return cards + `
`; }) .join(""); } updateSchoolYearLabel() { const lbl = document.getElementById("fc-current-school-year-label"); if (lbl) lbl.textContent = `${this.currentSchoolYearStart} – ${this.currentSchoolYearStart + 1}`; } processWeeks(rawWeeks) { return rawWeeks.map((w) => ({ id: `${w.week_iso_year}-W${w.week_iso_number}`, monthKey: `${w.year}-${String(w.month).padStart(2, "0")}`, monthName: w.month_name, weekLabel: w.week_label, dayDates: { mon: new Date(w.mon_date + "T00:00:00"), tue: new Date(w.tue_date + "T00:00:00"), wed: new Date(w.wed_date + "T00:00:00"), thu: new Date(w.thu_date + "T00:00:00"), fri: new Date(w.fri_date + "T00:00:00"), }, dayFlags: { mon: { events: [] }, tue: { events: [] }, wed: { events: [] }, thu: { events: [] }, fri: { events: [] }, }, totals: {}, })); } async fetchApi(url) { return pachaFetch(url); } async postApi(url, data) { return pachaFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); } async changeSchoolYear(delta) { this.currentSchoolYearStart += delta; this.updateSchoolYearLabel(); await this.refreshAllData(); } setupEventListeners() { const btnSettings = document.getElementById("btnOpenCalendarSettings"); if (btnSettings) btnSettings.addEventListener("click", openCalendarSettings); if (this.planningBody) { this.planningBody.addEventListener("mousedown", (e) => this.handleMouseDown(e), ); document.addEventListener("mousemove", (e) => this.handleMouseMove(e)); document.addEventListener("mouseup", (e) => this.handleMouseUp(e)); this.planningBody.addEventListener( "touchstart", (e) => this.handleTouchStart(e), { passive: false }, ); document.addEventListener("touchmove", (e) => this.handleTouchMove(e), { passive: false, }); document.addEventListener("touchend", (e) => this.handleTouchEnd(e)); } const scrollWrapper = document.getElementById("planningTable-wrapper"); if (scrollWrapper) { scrollWrapper.addEventListener("scroll", async () => { if (this._isAutoLoading) return; if ( scrollWrapper.scrollTop + scrollWrapper.clientHeight >= scrollWrapper.scrollHeight - 5 ) { this._isAutoLoading = true; await this.changeSchoolYear(1); scrollWrapper.scrollTop = 5; setTimeout(() => (this._isAutoLoading = false), 500); } else if (scrollWrapper.scrollTop === 0) { this._isAutoLoading = true; await this.changeSchoolYear(-1); scrollWrapper.scrollTop = scrollWrapper.scrollHeight - scrollWrapper.clientHeight - 5; setTimeout(() => (this._isAutoLoading = false), 500); } }); } if (this.monthCalendar) { this.monthCalendar.addEventListener("click", (e) => { const td = e.target.closest("td[data-date]"); if (td && td.dataset.date) { this.monthSelectedCells = [td]; this.showMenu(e.pageX, e.pageY, [td.dataset.date], true); } }); } document.addEventListener("click", (e) => this.closeMenusIfOutside(e)); const handleMenu = (e) => { const btn = e.target.closest("button"); if (btn && btn.dataset.action) this.handleMenuAction(btn.dataset); }; if (this.selectionMenu) this.selectionMenu.addEventListener("click", handleMenu); if (this.monthSelectionMenu) this.monthSelectionMenu.addEventListener("click", handleMenu); document .getElementById("fc-prev-month") ?.addEventListener("click", () => { this.currentMonth.setMonth(this.currentMonth.getMonth() - 1); this.renderMonthCalendar(); }); document .getElementById("fc-next-month") ?.addEventListener("click", () => { this.currentMonth.setMonth(this.currentMonth.getMonth() + 1); this.renderMonthCalendar(); }); document.getElementById("fc-today-btn")?.addEventListener("click", () => { this.currentMonth = new Date(); this.currentMonth.setDate(1); this.renderMonthCalendar(); }); document .getElementById("fc-prev-school-year") ?.addEventListener("click", () => this.changeSchoolYear(-1)); document .getElementById("fc-next-school-year") ?.addEventListener("click", () => this.changeSchoolYear(1)); document.querySelectorAll(".fc-view-button").forEach((btn) => btn.addEventListener("click", (e) => { document .querySelectorAll(".fc-view-button") .forEach((b) => b.classList.remove("fc-view-button--active")); e.target.classList.add("fc-view-button--active"); this.viewMode = e.target.dataset.view; this.renderMonthCalendar(); }), ); } handleMouseDown(e) { const td = e.target.closest("#planningTable td[data-date]"); if (!td) return; e.preventDefault(); this.clearSelection(); this.isSelecting = true; this.selectCell(td); } handleMouseMove(e) { if (!this.isSelecting) return; const td = e.target.closest("#planningTable td[data-date]"); if (td && !this.selectedCells.includes(td)) this.selectCell(td); } handleMouseUp(e) { if (!this.isSelecting) return; const td = e.target.closest("#planningTable td[data-date]"); if (td && !this.selectedCells.includes(td)) this.selectCell(td); this.isSelecting = false; if (this.selectedCells.length) this.showMenu( e.pageX, e.pageY, this.selectedCells.map((c) => c.dataset.date), false, ); } handleTouchStart(e) { const td = e.target.closest("#planningTable td[data-date]"); if (!td) return; e.preventDefault(); this.clearSelection(); this.isSelecting = true; this.selectCell(td); } handleTouchMove(e) { if (!this.isSelecting) return; e.preventDefault(); const touch = e.touches[0], target = document.elementFromPoint(touch.clientX, touch.clientY); if (!target) return; const td = target.closest("#planningTable td[data-date]"); if (td && !this.selectedCells.includes(td)) this.selectCell(td); } handleTouchEnd(e) { if (!this.isSelecting) return; this.isSelecting = false; if (this.selectedCells.length) this.showMenu( 0, 0, this.selectedCells.map((c) => c.dataset.date), false, ); } selectCell(cell) { cell.classList.add("fc-day--selected"); this.selectedCells.push(cell); } clearSelection() { this.selectedCells.forEach((c) => c.classList.remove("fc-day--selected")); this.selectedCells = []; if (this.selectionMenu) this.selectionMenu.style.display = "none"; if (this.monthSelectionMenu) this.monthSelectionMenu.style.display = "none"; } closeMenusIfOutside(e) { if ( !this.selectionMenu?.contains(e.target) && !this.monthSelectionMenu?.contains(e.target) && !this.menuJustOpened ) this.clearSelection(); } showMenu(x, y, dates, isMonthView) { const menu = isMonthView ? this.monthSelectionMenu : this.selectionMenu; if (!menu) return; this._currentBulkInfo = { dates }; const activeEventMap = new Set(); this.events.forEach((e) => { if (dates.includes(e.date)) { const personKey = e.person_id !== null && e.person_id !== undefined ? e.person_id.toString() : "0"; activeEventMap.add(`${e.type}_${personKey}`); } }); const activeLeaves = {}; this.parents.forEach((p) => (activeLeaves[p.id] = new Set())); this.leaves.forEach((l) => { if (dates.includes(l.leave_date) && activeLeaves[l.person_id]) activeLeaves[l.person_id].add(l.leave_type); }); const getActiveStyleE = (type, personStr, color) => activeEventMap.has(`${type}_${personStr}`) ? `border: 1px solid ${color} !important; background: var(--bg-soft) !important; color: ${color} !important; font-weight: 700;` : ""; const getActiveStyleL = (type, pid, color) => activeLeaves[pid]?.has(type) ? `border: 1px solid ${color} !important; background: var(--bg-soft) !important; color: ${color} !important; font-weight: 700;` : ""; const trLang = window.I18N || {}; const dateLabel = dates.length > 1 ? `${dates.length} Jours sélectionnés` : new Date(dates[0]).toLocaleDateString(window.appLang || "fr-FR", { weekday: "long", day: "numeric", month: "long", }); const trashSvg = ``; const buildHeader = (title, action, cat) => `
${title} ${action ? `` : ""}
`; let html = `
${dateLabel}
`; if (this.helpers.length > 0) { this.helpers.forEach((h) => { html += `
${buildHeader(h.name, "clear-type", "HELPER_" + h.id)}
`; }); } if (this.careModes.length > 0) { html += `
${buildHeader(trLang.fc_care_modes_title || "Modes de garde", "clear-type", "CARE_MODE")}
`; this.careModes.forEach((m) => { let iconHtml = m.toLowerCase() === "avis" ? ` ` : m.toLowerCase() === "centre" ? `🏫 ` : ""; const modeType = m.toUpperCase(); html += ``; }); html += `
`; } if (this.kids.length > 0) { html += `
${buildHeader(trLang.leg_pep_sick || "Maladie", "clear-type", "CHILD_SICK")}
`; this.kids.forEach((k) => { const color = k.color || "var(--danger)"; html += ``; }); html += `
`; } html += `
${buildHeader(trLang.fc_menu_kids_leaves || "Congés Adultes", null, null)}
`; const allParentLeaveTypes = new Set(); this.parents.forEach((p) => { (this.leaveMatrix[p.id] || []).forEach((l) => allParentLeaveTypes.add(l.type), ); }); this.parents.forEach((p) => { const pColor = p.color || "var(--primary)"; html += `
${p.name}
`; html += `
`; Array.from(allParentLeaveTypes).forEach((t) => { const hasThisLeave = (this.leaveMatrix[p.id] || []).some( (l) => l.type === t, ); if (hasThisLeave) { html += ``; } }); html += `
`; }); html += `
`; menu.innerHTML = html; let left = x + 10; if (left + 240 > window.innerWidth) left = x - 250; menu.style.left = `${left}px`; menu.style.top = `${y + 10}px`; menu.style.display = "block"; this.menuJustOpened = true; setTimeout(() => (this.menuJustOpened = false), 100); } async handleMenuAction(dataset) { if (this.selectionMenu) this.selectionMenu.style.display = "none"; if (this.monthSelectionMenu) this.monthSelectionMenu.style.display = "none"; const { action, type, person, pid, cat } = dataset; const dates = this._currentBulkInfo.dates; const upperCareModes = this.careModes.map((m) => m.toUpperCase()); try { if (action === "add") { let typesToClear = []; if (["HELPER_OFF", "HELPER_EXTRA"].includes(type)) typesToClear = ["HELPER_OFF", "HELPER_EXTRA"]; if (upperCareModes.includes(type)) typesToClear = [...upperCareModes]; if (type === "CHILD_SICK") typesToClear = ["CHILD_SICK"]; if (typesToClear.length) { await this.postApi( "/modules/family-calendar/includes/api/manage-event.php", { action: "bulk_delete_day_types_person", dates, types: typesToClear, person_id: parseInt(person) || 0, }, ); } await this.postApi( "/modules/family-calendar/includes/api/save-events.php", dates.map((d) => ({ date: d, type: type, duration: 1, person_id: parseInt(person) || 0, })), ); } else if (action === "clear-type") { let typesToClear = []; let personToClear = null; if (cat.startsWith("HELPER_")) { typesToClear = ["HELPER_OFF", "HELPER_EXTRA"]; personToClear = parseInt(cat.split("_")[1]) || 0; } else if (cat === "CARE_MODE") { typesToClear = [...upperCareModes]; personToClear = 0; } else if (cat === "CHILD_SICK") { typesToClear = ["CHILD_SICK"]; } await this.postApi( "/modules/family-calendar/includes/api/manage-event.php", { action: "bulk_delete_day_types_person", dates, types: typesToClear, person_id: personToClear, }, ); } else if (action === "add-leave") { await this.postApi( "/modules/family-calendar/includes/api/manage-leaf.php", { action: "bulk_delete_day_person", dates, person_id: parseInt(pid), }, ); await this.postApi( "/modules/family-calendar/includes/api/save-leaves.php", dates.map((d) => ({ date: d, person_id: parseInt(pid), leave_type: type, duration: 1, })), ); } else if (action === "clear-leaves-person") { await this.postApi( "/modules/family-calendar/includes/api/manage-leaf.php", { action: "bulk_delete_day_person", dates, person_id: parseInt(pid), }, ); } await this.refreshAllData(); } catch (e) { alert("Erreur: " + e.message); } } } new FamilyCalendar(); });