/** * family-calendar.js (Version Optimisée - API BDD & Décalage Vendredi) */ document.addEventListener("DOMContentLoaded", () => { const CONGE_TYPES = ["OFF_CAROLE", "EXTRA_OFF_CAROLE"]; const GUARDE_TYPES = ["CENTRE", "AVIS"]; const PEP_TYPES = ["PEP_SICK"]; const FAMILY = { ALEX: { id: 2, prefix: "alex" }, LAIA: { id: 3, prefix: "laia" }, }; const LEAVES_CONFIG = { CP: { startMonth: 8, // Le cycle commence en août (après la tolérance de juillet) defaultBalance: 25, }, JRA: { // Tu pourras ajouter les années suivantes ici yearlyTotals: { 2024: 10, // ex: 0.83 * 12 arrondi 2025: 10, 2026: 11, // ex: 0.9 * 12 arrondi }, defaultBalance: 10, toleranceMonths: 2, // Janvier et Février maxReport: 2, }, JA: { [FAMILY.ALEX.id]: { startMonth: 4, startDay: 29, defaultBalance: 4 }, [FAMILY.LAIA.id]: { startMonth: 10, startDay: 1, defaultBalance: 4 }, // Date de Laia à adapter }, }; 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; // Pour la modale des vacances this.isSelecting = false; this.selectedCells = []; this.monthSelectedCells = []; this._currentBulkInfo = null; this.dbEvents = []; this.fixedEvents = []; // Fériés statiques this.events = []; this.leaves = []; this.weeks = []; this.monthlyLeaveBalances = { 2: { CP: {}, JRA: {}, JA: {} }, 3: { CP: {}, JRA: {}, JA: {} }, }; 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(); // Prépare le selecteur d'année dans la modale await this.refreshAllData(); this.updateSchoolYearLabel(); } // Modifie dynamiquement le titre de la modale pour y insérer le selecteur d'année setupModalUI() { const headerH2 = document.querySelector(".fc-modal-header h2"); if (headerH2 && !document.getElementById("holidayYearSelect")) { headerH2.innerHTML = `Vacances Scolaires (Zone C) `; document .getElementById("holidayYearSelect") .addEventListener("change", (e) => { this.modalSelectedYear = parseInt(e.target.value); this.renderModalHolidays(); }); } } async refreshAllData() { try { const weeksData = await this.fetchApi( `/modules/family-calendar/includes/api/get-calendar-weeks-scolaire.php?school_year_start=${this.currentSchoolYearStart}`, ); this.weeks = this.processWeeks(weeksData.weeks || []); const eventsData = await this.fetchApi( "/modules/family-calendar/includes/api/get-events.php", ); this.dbEvents = (eventsData.events || []).map((e) => ({ ...e, duration: parseFloat(e.duration), })); this.fixedEvents = await this.fetchPublicHolidays(); const leavesData = await this.fetchApi( "/modules/family-calendar/includes/api/get-leaves.php", ); this.leaves = leavesData.leaves || []; const balancesData = await this.fetchApi( "/modules/family-calendar/includes/api/get-leave-balances.php", ); this.leaveBalances = balancesData.balances || []; // --- NOUVEAU : Chargement des correctifs de congés --- const snapshotsData = await this.fetchApi( "/modules/family-calendar/includes/api/get-leave-snapshots.php", ); 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); } } // Les jours fériés restent hardcodés car ils sont fixes. async fetchPublicHolidays() { try { // Appel à l'API officielle des jours fériés en métropole const res = await fetch( "https://calendrier.api.gouv.fr/jours-feries/metropole.json", ); const holidays = await res.json(); // L'API renvoie un objet : { "2024-01-01": "Jour de l'an", ... } // On le transforme en tableau compatible avec ton système d'événements return Object.keys(holidays).map((date, idx) => ({ id: `ph-${idx}`, date: date, name: holidays[date], // On garde le nom au cas où tu veuilles l'afficher plus tard type: "PUBLIC_HOLIDAY", duration: 1, })); } catch (error) { console.error( "Erreur lors de la récupération des jours fériés :", error, ); return []; // Évite de casser le calendrier si l'API de l'État est indisponible } } // --- RECONSTRUCTION DE LA MODALE VIA LA BDD (Par blocs consécutifs) --- renderModalHolidays() { if (!this.schoolHolidaysTableBody) return; // Filtrer les événements de type VACANCES_SCOLAIRES pour l'année scolaire sélectionnée const startDate = `${this.modalSelectedYear}-09-01`; const endDate = `${this.modalSelectedYear + 1}-08-31`; const yearHolidays = this.events.filter( (e) => e.type === "VACANCES_SCOLAIRES" && e.date >= startDate && e.date <= endDate, ); // S'il n'y a pas de vacances en base pour cette année, on affiche le bouton "Générer" if (yearHolidays.length === 0) { this.schoolHolidaysTableBody.innerHTML = `

Les vacances de cette année ne sont pas encore enregistrées.

`; document .getElementById("btnFetchGovHolidays") .addEventListener("click", (e) => { e.target.innerText = "Téléchargement en cours..."; e.target.disabled = true; this.fetchAndSaveGovHolidays(this.modalSelectedYear); }); return; } // 1. Tri chronologique strict des jours yearHolidays.sort((a, b) => new Date(a.date) - new Date(b.date)); // 2. Regroupement par blocs de jours consécutifs const blocks = []; let currentBlock = null; yearHolidays.forEach((e) => { const d = new Date(e.date + "T00:00:00"); // Force locale if (!currentBlock) { currentBlock = { start: d, end: d }; blocks.push(currentBlock); } else { // Calcul de l'écart en jours entre la date actuelle et la fin du bloc en cours const diffTime = d.getTime() - currentBlock.end.getTime(); const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24)); // Si l'écart est minime (<= 4 jours, pour absorber un éventuel week-end non stocké) // on considère qu'on est toujours dans la même période de vacances. if (diffDays <= 4) { currentBlock.end = d; } else { // Sinon, l'écart est grand : c'est une NOUVELLE période de vacances currentBlock = { start: d, end: d }; blocks.push(currentBlock); } } }); // 3. Rendu HTML et déduction des noms let html = ""; blocks.forEach((block) => { // Déduction intelligente du nom selon le mois de départ ET la durée const m = block.start.getMonth() + 1; const durationDays = Math.round( (block.end.getTime() - block.start.getTime()) / (1000 * 60 * 60 * 24), ) + 1; let name = "Vacances scolaires"; if (m === 10 || m === 11) { name = "Vacances de la Toussaint"; } else if (m === 12 || m === 1) { name = "Vacances de Noël"; } else if (m === 2 || m === 3) { name = "Vacances d'Hiver"; } else if (m === 4 || (m === 5 && durationDays > 6)) { name = "Vacances de Printemps"; } else if (m === 5 && durationDays <= 6) { name = "Pont de Mai (Ascension)"; } else if (m === 7 || m === 8) { name = "Vacances d'Été"; } html += ` ${name} ${block.start.toLocaleDateString("fr-FR")} ${block.end.toLocaleDateString("fr-FR")} `; }); this.schoolHolidaysTableBody.innerHTML = html; } // --- IMPORTATION DEPUIS L'API & SAUVEGARDE EN BDD --- async fetchAndSaveGovHolidays(yearStart) { try { const yearStr = `${yearStart}-${yearStart + 1}`; const url = `https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=annee_scolaire='${yearStr}' AND zones LIKE '%Zone C%'&limit=100`; const res = await fetch(url); const data = await res.json(); const rawRecords = data.results || []; // Dédoublonnage const uniqueMap = new Map(); rawRecords.forEach((r) => { const key = `${r.description}|${r.start_date}`; if (!uniqueMap.has(key)) uniqueMap.set(key, r); }); const payload = []; Array.from(uniqueMap.values()).forEach((r) => { const startDateStr = r.start_date.split("T")[0]; const endDateStr = r.end_date.split("T")[0]; let curr = new Date(startDateStr + "T00:00:00"); const end = new Date(endDateStr + "T00:00:00"); // REGLE METIER : Si le 1er jour est un VENDREDI (jour=5), on décale le début au SAMEDI. if (curr.getDay() === 5) { curr.setDate(curr.getDate() + 1); } while (curr < end) { const iso = this.getLocalIsoDate(curr); payload.push({ date: iso, type: "VACANCES_SCOLAIRES", duration: 1, person: r.description, // ASTUCE: On stocke le nom de la vacance ici ! }); curr.setDate(curr.getDate() + 1); } }); if (payload.length > 0) { // On envoie le gros lot à la base de données via l'API existante await this.postApi( "/modules/family-calendar/includes/api/save-events.php", payload, ); await this.refreshAllData(); // Recharge tout, ce qui mettra à jour la modale } else { alert( "Aucune donnée trouvée sur l'API du gouvernement pour cette année.", ); document.getElementById("btnFetchGovHolidays").innerText = "Réessayer"; document.getElementById("btnFetchGovHolidays").disabled = false; } } catch (e) { console.error("Erreur API", e); alert("Erreur lors de la connexion à l'API gouvernementale."); } } // ================================================================ // LE RESTE DU CODE (AFFICHAGE) RESTE INCHANGÉ MAIS OPTIMISÉ // ================================================================ reprocessAndRender() { this.reprocessEvents(); this.calculateMonthlyBalances(); this.initSummaryControls(); this.renderTable(); this.renderMonthCalendar(); } reprocessEvents() { this.weeks.forEach((w) => { Object.keys(w.totals).forEach((k) => (w.totals[k] = 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; const typeMap = { OFF_CAROLE: "offCarole", EXTRA_OFF_CAROLE: "extraOffCarole", CENTRE: "centre", AVIS: "avis", PEP_SICK: "pepSick", }; if (typeMap[e.type]) w.totals[typeMap[e.type]] += 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; const prefix = l.person_id === FAMILY.ALEX.id ? FAMILY.ALEX.prefix : l.person_id === FAMILY.LAIA.id ? FAMILY.LAIA.prefix : null; if (prefix) w.totals[`${prefix}${l.leave_type}`] += dur; } }); let workingDays = 0; Object.values(w.dayDates).forEach((d) => { if (!this.publicHolidayDates.has(this.getLocalIsoDate(d))) workingDays++; }); w.totals.presencePep = Math.max( 0, workingDays - (w.totals.offCarole + w.totals.extraOffCarole + w.totals.pepSick), ); }); } calculateMonthlyBalances() { const balances = { [FAMILY.ALEX.id]: { CP: {}, JRA: {}, JA: {} }, [FAMILY.LAIA.id]: { CP: {}, JRA: {}, JA: {} }, }; // Liste des mois actuellement affichés dans le planning const ymSet = new Set(); this.weeks.forEach((w) => ymSet.add(w.monthKey)); const ymList = Array.from(ymSet).sort(); // Pré-calcul de l'utilisation par mois const usageByMonth = {}; this.leaves.forEach((l) => { const pid = l.person_id; const type = l.leave_type; const 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); }); [FAMILY.ALEX.id, FAMILY.LAIA.id].forEach((pid) => { ["CP", "JRA", "JA"].forEach((type) => { ymList.forEach((ym) => { const [currYear, currMonth] = ym.split("-").map(Number); let cycleStartStr = ""; let initialBalance = 0; // --- 1. RECHERCHE D'UN CORRECTIF MANUEL (SNAPSHOT) --- // On cherche le snapshot le plus récent qui est inférieur ou égal au mois en cours de calcul const latestSnapshot = (this.leaveSnapshots || []) .filter( (s) => s.person_id == pid && s.leave_type == type && s.snapshot_date.substring(0, 7) <= ym, ) // On trie du plus récent au plus ancien pour prendre le premier .sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date), )[0]; if (latestSnapshot) { // Si on trouve un correctif, il devient notre nouveau "point zéro" cycleStartStr = latestSnapshot.snapshot_date.substring(0, 7); initialBalance = parseFloat(latestSnapshot.remaining_balance); // Utilisation de VOTRE nom de colonne } // --- 2. SINON, CALCUL CLASSIQUE PAR DÉFAUT --- else { if (type === "CP") { const refYear = currMonth >= LEAVES_CONFIG.CP.startMonth ? currYear : currYear - 1; cycleStartStr = `${refYear}-${String(LEAVES_CONFIG.CP.startMonth).padStart(2, "0")}`; const dbBal = this.leaveBalances.find( (b) => b.person_id == pid && b.leave_type == "CP" && b.balance_year == refYear, ); initialBalance = dbBal ? parseFloat(dbBal.initial_balance) : LEAVES_CONFIG.CP.defaultBalance; } else if (type === "JRA") { cycleStartStr = `${currYear}-01`; initialBalance = LEAVES_CONFIG.JRA.yearlyTotals[currYear] || LEAVES_CONFIG.JRA.defaultBalance; if (currMonth <= LEAVES_CONFIG.JRA.toleranceMonths) { const prevYear = currYear - 1; const prevInitial = LEAVES_CONFIG.JRA.yearlyTotals[prevYear] || LEAVES_CONFIG.JRA.defaultBalance; let usedPrevYear = 0; for (let m = 1; m <= 12; m++) { const mStr = `${prevYear}-${String(m).padStart(2, "0")}`; usedPrevYear += usageByMonth[pid]?.[type]?.[mStr] || 0; } const remainingPrevYear = Math.max( 0, prevInitial - usedPrevYear, ); initialBalance += Math.min( remainingPrevYear, LEAVES_CONFIG.JRA.maxReport, ); } } else if (type === "JA") { const configJA = LEAVES_CONFIG.JA[pid]; const isPastAnniversary = currMonth > configJA.startMonth || (currMonth === configJA.startMonth && configJA.startDay === 1); const refYear = isPastAnniversary ? currYear : currYear - 1; cycleStartStr = `${refYear}-${String(configJA.startMonth).padStart(2, "0")}`; const dbBal = this.leaveBalances.find( (b) => b.person_id == pid && b.leave_type == "JA" && b.balance_year == refYear, ); initialBalance = dbBal ? parseFloat(dbBal.initial_balance) : configJA.defaultBalance; } } // --- 3. DÉDUCTION DES CONGÉS PRIS DEPUIS LE POINT ZÉRO --- let usedBeforeCurrentMonth = 0; Object.keys(usageByMonth[pid]?.[type] || {}).forEach((usedYm) => { // On ne déduit que ce qui a été posé entre le début du cycle (ou la date du snapshot) et le mois en cours if (usedYm >= cycleStartStr && usedYm < ym) { usedBeforeCurrentMonth += usageByMonth[pid][type][usedYm]; } }); const available = Math.max( 0, initialBalance - usedBeforeCurrentMonth, ); const usedInMonth = usageByMonth[pid]?.[type]?.[ym] || 0; balances[pid][type][ym] = { availableAtMonthStart: available, usedInMonth: usedInMonth, }; }); }); }); 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)) : ""; this.weeks.forEach((w, idx) => { const tr = document.createElement("tr"); 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.textContent = 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.textContent = String(dateObj.getDate()).padStart(2, "0"); td.className = "col-day"; w.dayFlags[d].events.forEach((evt) => { if (evt.type === "OFF_CAROLE") td.classList.add("fc-day--off-carole"); if (evt.type === "EXTRA_OFF_CAROLE") td.classList.add("fc-day--extra-off-carole"); 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 === "CENTRE") td.classList.add("fc-day--centre"); if (evt.type === "AVIS") td.classList.add("fc-day--avis"); if (evt.type === "PEP_SICK") td.innerHTML += `🤒`; }); const dayLeaves = this.leaves.filter((l) => l.leave_date === iso); if (dayLeaves.length) { let html = `
`; if (dayLeaves.some((l) => l.person_id === 2)) html += `A`; if (dayLeaves.some((l) => l.person_id === 3)) html += `L`; html += `
`; td.innerHTML += html; } tr.appendChild(td); }); [ "offCarole", "extraOffCarole", "centre", "avis", "pepSick", "presencePep", ].forEach((k) => { const td = document.createElement("td"); td.className = "col-total"; td.textContent = fmt(w.totals[k]); tr.appendChild(td); }); if (!processedLeavesCols[w.monthKey]) { processedLeavesCols[w.monthKey] = true; const span = monthSpans[w.monthKey]; const ym = w.monthKey; const renderPersonCols = (pid, prefix) => { ["CP", "JRA", "JA"].forEach((type) => { const info = this.monthlyLeaveBalances[pid][type][ym]; const tdAv = document.createElement("td"); tdAv.className = `${prefix}-sub ${prefix}-av`; tdAv.rowSpan = span; tdAv.textContent = info ? fmt(info.availableAtMonthStart) : "-"; tr.appendChild(tdAv); const tdUse = document.createElement("td"); tdUse.className = `${prefix}-sub ${prefix}-use`; tdUse.rowSpan = span; tdUse.textContent = info ? fmt(info.usedInMonth) : ""; tr.appendChild(tdUse); }); }; renderPersonCols(FAMILY.ALEX.id, `col-${FAMILY.ALEX.prefix}`); renderPersonCols(FAMILY.LAIA.id, `col-${FAMILY.LAIA.prefix}`); } this.planningBody.appendChild(tr); }); } renderMonthCalendar() { if (!this.monthCalendar) return; this.monthCalendar.innerHTML = ""; const y = this.currentMonth.getFullYear(); const m = this.currentMonth.getMonth(); const titleEl = document.querySelector("#fc-current-month-year"); if (titleEl) { if (this.viewMode === "year") { titleEl.textContent = `Année scolaire ${this.currentSchoolYearStart} – ${this.currentSchoolYearStart + 1}`; } else if (this.viewMode === "2months") { const nextM = new Date(y, m + 1, 1); const m1 = new Intl.DateTimeFormat("fr-FR", { month: "long" }).format( this.currentMonth, ); const m2 = new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric", }).format(nextM); titleEl.textContent = `${m1} - ${m2}`; } else { titleEl.textContent = new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric", }).format(this.currentMonth); } } if (this.viewMode === "year") { this.renderYearView(); return; } if (this.viewMode === "2months") { this.renderTwoMonthsView(); return; } this.monthCalendar.innerHTML = this.generateMonthHTML(y, m); } renderTwoMonthsView() { const y = this.currentMonth.getFullYear(); const m = this.currentMonth.getMonth(); const nextDate = new Date(y, m + 1, 1); let html = `
`; html += `
${new Intl.DateTimeFormat("fr-FR", { month: "long" }).format(this.currentMonth)}
${this.generateMonthHTML(y, m)}
`; html += `
${new Intl.DateTimeFormat("fr-FR", { month: "long" }).format(nextDate)}
${this.generateMonthHTML(nextDate.getFullYear(), nextDate.getMonth())}
`; html += `
`; this.monthCalendar.innerHTML = html; } renderYearView() { const startYear = this.currentSchoolYearStart; let html = '
'; for (let i = 8; i < 12; i++) html += `
${new Intl.DateTimeFormat("fr-FR", { month: "long" }).format(new Date(startYear, i))}
${this.generateMonthHTML(startYear, i)}
`; for (let i = 0; i < 8; i++) html += `
${new Intl.DateTimeFormat("fr-FR", { month: "long" }).format(new Date(startYear + 1, i))}
${this.generateMonthHTML(startYear + 1, i)}
`; html += "
"; this.monthCalendar.innerHTML = html; } 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; const firstDay = new Date(year, month, 1); let startDay = (firstDay.getDay() + 6) % 7; if (startDay < 5) { for (let i = 0; i < startDay; i++) { html += ``; currentRenderedCols++; } } for (let dayCounter = 1; dayCounter <= daysInMonth; dayCounter++) { const dateObj = new Date(year, month, dayCounter); const dayOfWeek = dateObj.getDay(); if (dayOfWeek === 0 || dayOfWeek === 6) continue; if (currentRenderedCols === 5) { html += ``; currentRenderedCols = 0; } const iso = this.getLocalIsoDate(dateObj); let cls = "fc-month-day"; 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 === "OFF_CAROLE")) cls += " fc-day--off-carole"; if (dayEvts.some((e) => e.type === "EXTRA_OFF_CAROLE")) cls += " fc-day--extra-off-carole"; if (dayEvts.some((e) => ["CENTRE", "AVIS"].includes(e.type))) { cls += " fc-day--has-guard"; if (dayEvts.some((e) => e.type === "CENTRE")) cls += " fc-day--centre"; if (dayEvts.some((e) => e.type === "AVIS")) cls += " fc-day--avis"; } let content = `
${dayCounter}`; if (dayEvts.some((e) => e.type === "PEP_SICK")) content += `🤒`; const dayLeaves = this.leaves.filter((l) => l.leave_date === iso); if (dayLeaves.length) { content += `
`; if (dayLeaves.some((l) => l.person_id === 2)) content += `A `; if (dayLeaves.some((l) => l.person_id === 3)) content += `L`; content += `
`; } content += `
`; html += ``; currentRenderedCols++; } while (currentRenderedCols < 5 && currentRenderedCols > 0) { html += ``; currentRenderedCols++; } html += `
${d}
${content}
`; return html; } initSummaryControls() { const typeSelect = document.getElementById("summType"); const valueSelect = document.getElementById("summValue"); if (!typeSelect || !valueSelect) return; const years = new Set(); const months = new Set(); this.weeks.forEach((w) => { Object.values(w.dayDates).forEach((d) => { years.add(d.getFullYear()); const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; months.add(mKey); }); }); const sortedYears = Array.from(years).sort(); const sortedMonths = Array.from(months).sort(); const populateValues = () => { const currentType = typeSelect.value; const previousValue = valueSelect.value; valueSelect.innerHTML = ""; if (currentType === "year") { sortedYears.forEach((y) => { const opt = document.createElement("option"); opt.value = y; opt.textContent = y; valueSelect.appendChild(opt); }); if (sortedYears.includes(parseInt(previousValue))) { valueSelect.value = previousValue; } else { const currentYear = new Date().getFullYear(); if (sortedYears.includes(currentYear)) valueSelect.value = currentYear; } } else { sortedMonths.forEach((m) => { const [y, mo] = m.split("-"); const dateObj = new Date(y, mo - 1, 1); const label = new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric", }).format(dateObj); const opt = document.createElement("option"); opt.value = m; opt.textContent = label; valueSelect.appendChild(opt); }); const now = new Date(); const nowIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; if (sortedMonths.includes(nowIso)) { valueSelect.value = nowIso; } } this.updateGlobalSummary(); }; if (!this.summaryListenersAttached) { typeSelect.addEventListener("change", populateValues); valueSelect.addEventListener("change", () => this.updateGlobalSummary(), ); this.summaryListenersAttached = true; } populateValues(); } updateGlobalSummary() { const div = document.getElementById("globalSummary"); const typeSelect = document.getElementById("summType"); const valueSelect = document.getElementById("summValue"); if (!div || (typeSelect && valueSelect && !valueSelect.value)) return; const filterType = typeSelect ? typeSelect.value : "year"; const filterValue = valueSelect ? valueSelect.value : new Date().getFullYear().toString(); const stats = { off: 0, extra: 0, sick: 0, pep: 0, totalWorking: 0 }; this.weeks.forEach((w) => { Object.entries(w.dayDates).forEach(([dayName, dateObj]) => { let match = false; if (filterType === "year") { if (dateObj.getFullYear().toString() === filterValue) match = true; } else { const isoMonth = this.getLocalIsoDate(dateObj).slice(0, 7); if (isoMonth === filterValue) match = true; } if (match) { const dayEvents = w.dayFlags[dayName].events; dayEvents.forEach((e) => { const dur = parseFloat(e.duration) || 1; if (e.type === "OFF_CAROLE") stats.off += dur; if (e.type === "EXTRA_OFF_CAROLE") stats.extra += dur; if (e.type === "PEP_SICK") stats.sick += dur; }); const dateIso = this.getLocalIsoDate(dateObj); if (!this.publicHolidayDates.has(dateIso)) { stats.totalWorking++; let dayAbsence = 0; dayEvents.forEach((e) => { if ( ["OFF_CAROLE", "EXTRA_OFF_CAROLE", "PEP_SICK"].includes( e.type, ) ) { dayAbsence += parseFloat(e.duration) || 1; } }); stats.pep += Math.max(0, 1 - dayAbsence); } } }); }); div.innerHTML = `
Off Carole${parseFloat(stats.off.toFixed(1))} jours
Extra Off Carole${parseFloat(stats.extra.toFixed(1))} jours
Pep Malade${parseFloat(stats.sick.toFixed(1))} jours
Présence Pep${parseFloat(stats.pep.toFixed(1))} jours
`; } 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: { offCarole: 0, extraOffCarole: 0, centre: 0, avis: 0, pepSick: 0, presencePep: 0, alexCP: 0, alexJRA: 0, alexJA: 0, laiaCP: 0, laiaJRA: 0, laiaJA: 0, }, })); } async fetchApi(url) { return fetch(url).then((r) => r.json()); } async postApi(url, data) { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!res.ok) throw new Error(await res.text()); return res.json(); } async changeSchoolYear(delta) { this.currentSchoolYearStart += delta; this.updateSchoolYearLabel(); await this.refreshAllData(); } setupEventListeners() { if (this.planningBody) { // Souris (PC) this.planningBody.addEventListener("mousedown", (e) => this.handleMouseDown(e), ); document.addEventListener("mousemove", (e) => this.handleMouseMove(e)); document.addEventListener("mouseup", (e) => this.handleMouseUp(e)); // Tactile (Mobile/Tablette) 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)); } // --- GESTION MODALE VACANCES SCOLAIRES --- const btnOpen = document.getElementById("btnOpenHolidays"); const btnClose = document.getElementById("btnCloseHolidays"); const modal = document.getElementById("modalHolidays"); if (btnOpen && modal) { btnOpen.addEventListener("click", () => { modal.style.display = "flex"; // Remet l'année par défaut sur l'année actuellement affichée dans le calendrier const yearSelect = document.getElementById("holidayYearSelect"); if (yearSelect) yearSelect.value = this.currentSchoolYearStart; this.modalSelectedYear = this.currentSchoolYearStart; this.renderModalHolidays(); }); } if (btnClose && modal) btnClose.addEventListener( "click", () => (modal.style.display = "none"), ); if (modal) modal.addEventListener("click", (e) => { if (e.target === modal) modal.style.display = "none"; }); // --- GESTION MODALE CORRECTIF DES SOLDES (SNAPSHOTS) --- const btnSnap = document.getElementById("btnOpenSnapshotModal"); const modalSnap = document.getElementById("modalSnapshot"); const btnCloseSnap = document.getElementById("btnCloseSnapshot"); const formSnap = document.getElementById("formSnapshot"); if (btnSnap && modalSnap) { btnSnap.addEventListener("click", () => { modalSnap.style.display = "flex"; // Pré-remplir la date avec le 1er jour du mois en cours const today = new Date(); const y = today.getFullYear(); const m = String(today.getMonth() + 1).padStart(2, "0"); const snapDateInput = document.getElementById("snapDate"); if (snapDateInput) snapDateInput.value = `${y}-${m}-01`; }); } if (btnCloseSnap && modalSnap) { btnCloseSnap.addEventListener( "click", () => (modalSnap.style.display = "none"), ); } if (modalSnap) { modalSnap.addEventListener("click", (e) => { if (e.target === modalSnap) modalSnap.style.display = "none"; }); } if (formSnap) { formSnap.addEventListener("submit", async (e) => { e.preventDefault(); // Empêche le rechargement de la page const payload = { person_id: document.getElementById("snapPerson").value, leave_type: document.getElementById("snapType").value, snapshot_date: document.getElementById("snapDate").value, remaining_balance: document.getElementById("snapBalance").value, }; try { // On envoie à la nouvelle API await this.postApi( "/modules/family-calendar/includes/api/save-leave-snapshot.php", payload, ); modalSnap.style.display = "none"; formSnap.reset(); // On vide le formulaire // On rafraîchit tout le calendrier pour appliquer le nouveau solde immédiatement await this.refreshAllData(); } catch (error) { alert("Erreur lors de la sauvegarde du correctif."); } }); } // --- GESTION DU RESTE DE L'INTERFACE --- 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-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) return; const dates = this.selectedCells.map((c) => c.dataset.date); this.showMenu(e.pageX, e.pageY, dates, false); } // --- GESTION DU TACTILE (Swipe pour sélectionner) --- handleTouchStart(e) { const td = e.target.closest("#planningTable td[data-date]"); if (!td) return; // Si l'utilisateur touche une case, on bloque le scroll de la page pour le laisser sélectionner e.preventDefault(); this.clearSelection(); this.isSelecting = true; this.selectCell(td); } handleTouchMove(e) { if (!this.isSelecting) return; e.preventDefault(); // Bloque le défilement de l'écran pendant qu'on glisse le doigt const touch = e.touches[0]; // elementFromPoint permet de savoir sur quelle case se trouve le doigt actuellement const 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) return; const dates = this.selectedCells.map((c) => c.dataset.date); // Sur mobile, on s'en fiche de X et Y car le menu s'affiche en bas de l'écran this.showMenu(0, 0, dates, 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 dateLabel = dates.length > 1 ? `${dates.length} jours` : new Date(dates[0]).toLocaleDateString("fr-FR"); // Uniformisation : On définit l'icône SVG une seule fois const trashSvg = ``; // En-tête principal avec la date let html = `
${dateLabel}
`; // Fonction utilitaire pour créer une ligne de titre avec l'icône SVG const buildHeader = (title, action, cat) => `
${title} ${action ? `` : ""}
`; // Section Carole html += `
${buildHeader("Congés Carole", "clear-type", "CONGE")}
`; // Section Garde html += `
${buildHeader("Garde", "clear-type", "GARDE")}
`; // Section Pep html += `
${buildHeader("Pep", "clear-type", "PEP")}
`; // --- SECTION ALEX & LAIA AVEC SUPPRESSION INDÉPENDANTE, UNIFORMISÉE ET ALIGNÉE À CÔTÉ (INLINE) --- html += `
${buildHeader("Congés Enfants", null, null)}
`; ["CP", "JRA", "JA"].forEach((t) => { html += ``; }); html += `
Alex
Laia
`; menu.innerHTML = html; // Positionnement intelligent du menu const menuWidth = 240; let left = x + 10; if (left + menuWidth > window.innerWidth) left = x - menuWidth - 10; 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, pid, cat } = dataset; const dates = this._currentBulkInfo.dates; try { if (action === "add") { let typesToClear = []; if (["OFF_CAROLE", "EXTRA_OFF_CAROLE"].includes(type)) typesToClear = CONGE_TYPES; if (["CENTRE", "AVIS"].includes(type)) typesToClear = GUARDE_TYPES; if (type === "PEP_SICK") typesToClear = PEP_TYPES; if (typesToClear.length) { await this.postApi( "/modules/family-calendar/includes/api/manage-event.php", { action: "bulk_delete_day_types", dates, types: typesToClear }, ); } const payload = dates.map((d) => ({ date: d, type: type, duration: 1, person: "Carole", })); await this.postApi( "/modules/family-calendar/includes/api/save-events.php", payload, ); } else if (action === "clear-type") { let typesToClear = []; if (cat === "CONGE") typesToClear = CONGE_TYPES; if (cat === "GARDE") typesToClear = GUARDE_TYPES; if (cat === "PEP") typesToClear = PEP_TYPES; await this.postApi( "/modules/family-calendar/includes/api/manage-event.php", { action: "bulk_delete_day_types", dates, types: typesToClear }, ); } 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), }, ); const payload = dates.map((d) => ({ date: d, person_id: parseInt(pid), // On s'assure que c'est bien un format numérique leave_type: type, duration: 1, })); await this.postApi( "/modules/family-calendar/includes/api/save-leaves.php", payload, ); } else if (action === "clear-leaves-person") { // --- NOUVELLE ACTION : Suppression ciblée (Alex OU Laia selon le pid) --- await this.postApi( "/modules/family-calendar/includes/api/manage-leaf.php", { action: "bulk_delete_day_person", dates, person_id: parseInt(pid), }, ); } else if (action === "clear-leaves") { // --- ACTION GLOBALE : Gardée au cas où vous en auriez besoin ailleurs --- await this.postApi( "/modules/family-calendar/includes/api/manage-leaf.php", { action: "bulk_delete_day_person", dates, person_id: 2 }, ); await this.postApi( "/modules/family-calendar/includes/api/manage-leaf.php", { action: "bulk_delete_day_person", dates, person_id: 3 }, ); } await this.refreshAllData(); } catch (e) { alert("Erreur action: " + e.message); } } } new FamilyCalendar(); });