Files
HouseHub/modules/family-calendar/family-calendar.js
T
FeFeClochette 6459c225ac
Deploy HouseHub / deploy (push) Successful in 2s
settings calendar
2026-06-11 17:57:09 +02:00

1742 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* family-calendar.js (Version Optimisée - API BDD, Décalage Vendredi & Paramètres Dynamiques)
*/
// ============================================================================
// 1. VARIABLES GLOBALES & LOGIQUE DE CONFIGURATION (SETTINGS)
// ============================================================================
let calGlobalData = null;
let currentSelectedMemberId = null;
let localCareModes = [];
// Ouvre et initialise la modale avec les données fraîches de la BDD
async function openCalendarSettings() {
try {
const res = await pachaFetch(
"/modules/family-calendar/includes/api/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) =>
`<option value="${p.id}" data-role="${p.role}">${p.name} (${p.role === "parent" ? tr("fc_role_adult") : tr("fc_role_child")})</option>`,
)
.join("");
}
switchCalendarTab("foyer");
const modalSettings = document.getElementById("modalCalendarSettings");
if (modalSettings) {
modalSettings.classList.add("open");
document.body.classList.add("no-scroll");
}
} catch (err) {
alert("Erreur : " + err.message);
}
}
function closeCalendarSettings() {
document.getElementById("modalCalendarSettings").classList.remove("open");
document.body.classList.remove("no-scroll");
}
function switchCalendarTab(tabName) {
document
.querySelectorAll(".cal-settings-pane")
.forEach((p) => (p.style.display = "none"));
document
.querySelectorAll(".bs-tab-btn")
.forEach((b) => b.classList.remove("active"));
if (tabName === "foyer") {
document.getElementById("cal-pane-foyer").style.display = "block";
document.getElementById("tab-btn-foyer").classList.add("active");
} else {
document.getElementById("cal-pane-membres").style.display = "block";
document.getElementById("tab-btn-membres").classList.add("active");
loadMemberConfigView();
}
}
function renderCareModeTags() {
const container = document.getElementById("careModesContainer");
container.innerHTML = localCareModes
.map(
(mode, i) => `
<span class="bs-rule-tag" style="background:var(--bg-panel); font-size:0.85rem; padding: 4px 10px; display:inline-flex; align-items:center; gap:6px; border-radius:12px;">
${mode} <b style="cursor:pointer; color:var(--danger); font-size:1.1rem; line-height:1;" onclick="removeCareModeTag(${i})">×</b>
</span>
`,
)
.join("");
}
function addCareModeTag() {
const input = document.getElementById("inputNewCareMode");
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/settings.php",
{ method: "POST", body: formData },
);
if (!res.success) throw new Error(res.error);
alert(
window.FAMILY_CONFIG?.i18n?.settings_updated ||
"Configuration enregistrée ! ✨",
);
window.location.reload(); // On recharge pour appliquer la zone et les colonnes de garde
} catch (err) {
alert("Erreur : " + err.message);
}
}
function loadMemberConfigView() {
const select = document.getElementById("selectCalMember");
const zone = document.getElementById("memberConfigZone");
if (!select || !select.value || !zone) return;
currentSelectedMemberId = parseInt(select.value);
const role = select.options[select.selectedIndex].dataset.role;
if (role === "enfant") {
zone.innerHTML = `
<h5 style="margin:0 0 8px 0; color:var(--text-main);">${tr("fc_care_modes_title")}</h5>
<p style="font-size:0.85rem; color:var(--text-muted); margin-bottom:12px;">${tr("fc_care_modes_desc")}</p>
<div style="display:flex; flex-direction:column; gap:6px;">
${
localCareModes
.map(
(m) => `
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:0.9rem;">
<input type="checkbox" value="${m}" checked> ${m}
</label>
`,
)
.join("") || `<em>${tr("fc_no_care_mode")}</em>`
}
</div>
<button class="pf-btn pf-btn-primary" style="margin-top:15px; width:100%;" onclick="alert('${tr("fc_child_saved")}')">${tr("btn_save_rights")}</button>
`;
} else {
// 1. Récupération STRICTE des congés existants en BDD (Aucun forçage)
let userLeaves = calGlobalData.leaves[currentSelectedMemberId] || [];
// 2. Génération des lignes du tableau dynamiquement (Version compactée)
const tableRows = userLeaves
.map(
(l) => `
<tr class="js-leave-row" style="border-bottom: 1px solid var(--border-light);">
<td style="padding: 6px 2px;"><input type="text" class="pf-input js-leave-type" value="${l.type}" placeholder="CP" required style="width:50px; text-transform:uppercase; padding:6px 4px; font-size:0.85rem; text-align:center;"></td>
<td style="padding: 6px 2px;">
<select class="pf-input js-leave-method" style="padding:6px 2px; font-size:0.85rem; width:80px;">
<option value="FIXED" ${l.method === "FIXED" ? "selected" : ""}>Fixe</option>
<option value="ACCUMULATED" ${l.method === "ACCUMULATED" ? "selected" : ""}>Cumul</option>
</select>
</td>
<td style="padding: 6px 2px;"><input type="number" step="0.5" class="pf-input js-leave-allowance" value="${l.allowance || 0}" placeholder="0" required style="width:55px; padding:6px 4px; font-size:0.85rem; text-align:center;"></td>
<td style="padding: 6px 2px;"><input type="date" class="pf-input js-leave-date" value="${l.date}" required style="padding:6px 4px; font-size:0.85rem; width:115px;"></td>
<td style="padding: 6px 2px; text-align:right;"><button type="button" class="btn-icon-action delete" style="padding:4px; margin:0;" onclick="this.closest('tr').remove()" title="Supprimer">🗑️</button></td>
</tr>
`,
)
.join("");
zone.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:12px;">
<div>
<h5 style="margin:0 0 4px 0; color:var(--text-main); font-size:1rem;">🗓️ Matrice des Congés</h5>
<p style="font-size:0.8rem; color:var(--text-muted); margin:0; line-height:1.3;">Définissez les quotas et méthodes d'acquisition.</p>
</div>
<button type="button" class="pf-btn pf-btn-secondary" style="padding:6px 10px; font-size:0.8rem; white-space:nowrap;" onclick="addLeaveRowToMatrix()"> Ajouter</button>
</div>
<div style="overflow-x:hidden; margin: 0 -5px;">
<table style="width:100%; border-collapse:collapse; margin-bottom:15px; table-layout: fixed;">
<thead>
<tr style="font-size:0.75rem; text-align:left; color:var(--text-muted); text-transform:uppercase;">
<th style="padding:0 2px 8px; width: 15%;">Code</th>
<th style="padding:0 2px 8px; width: 25%;">Méthode</th>
<th style="padding:0 2px 8px; width: 20%;">Quota</th>
<th style="padding:0 2px 8px; width: 30%;">Renouv. / Fin</th>
<th style="padding:0 2px 8px; width: 10%;"></th>
</tr>
</thead>
<tbody id="tbodyLeaveMatrix">
${tableRows || `<tr><td colspan="5" id="emptyMatrixRow" style="text-align:center; padding:20px; color:var(--text-muted); font-style:italic; font-size:0.9rem;">Aucun compteur défini.</td></tr>`}
</tbody>
</table>
</div>
<div style="display:flex; justify-content:flex-end; border-top:1px solid var(--border-light); padding-top:12px;">
<button type="button" class="pf-btn pf-btn-primary" style="padding:8px 16px;" onclick="submitMemberLeaves()">Enregistrer</button>
</div>
`;
}
}
window.addLeaveRowToMatrix = function () {
const tbody = document.getElementById("tbodyLeaveMatrix");
const empty = document.getElementById("emptyMatrixRow");
if (empty) empty.remove();
if (!tbody) return;
const tr = document.createElement("tr");
tr.className = "js-leave-row";
tr.style.borderBottom = "1px solid var(--border-light)";
tr.innerHTML = `
<td style="padding: 6px 2px;"><input type="text" class="pf-input js-leave-type" placeholder="CP" required style="width:100%; text-transform:uppercase; padding:6px 4px; font-size:0.85rem; text-align:center; box-sizing: border-box;"></td>
<td style="padding: 6px 2px;">
<select class="pf-input js-leave-method" style="width:100%; padding:6px 2px; font-size:0.85rem; box-sizing: border-box;">
<option value="FIXED">Fixe</option>
<option value="ACCUMULATED">Cumul</option>
</select>
</td>
<td style="padding: 6px 2px;"><input type="number" step="0.5" class="pf-input js-leave-allowance" placeholder="0" required style="width:100%; padding:6px 4px; font-size:0.85rem; text-align:center; box-sizing: border-box;"></td>
<td style="padding: 6px 2px;"><input type="date" class="pf-input js-leave-date" required style="width:100%; padding:6px 4px; font-size:0.85rem; box-sizing: border-box;"></td>
<td style="padding: 6px 2px; text-align:right;"><button type="button" class="btn-icon-action delete" style="padding:4px; margin:0;" onclick="this.closest('tr').remove()" title="Supprimer">🗑️</button></td>
`;
tbody.appendChild(tr);
};
async function submitMemberLeaves() {
// 🟢 Sécurité : on s'assure qu'un membre est bien sélectionné
if (!currentSelectedMemberId || isNaN(currentSelectedMemberId)) {
alert("Erreur technique : Aucun membre valide sélectionné.");
return;
}
try {
const rows = document.querySelectorAll(".js-leave-row");
// 🟢 Récupération enrichie avec "method" et "allowance"
const leavesPayload = Array.from(rows)
.map((row) => ({
type: row.querySelector(".js-leave-type").value.trim().toUpperCase(),
method: row.querySelector(".js-leave-method").value, // <-- NOUVEAU
allowance: row.querySelector(".js-leave-allowance").value, // <-- NOUVEAU
date: row.querySelector(".js-leave-date").value,
}))
.filter((l) => l.type && l.date); // Filtre de sécurité
const formData = new FormData();
formData.append("action", "save_member_leaves");
formData.append("person_id", currentSelectedMemberId);
formData.append("leaves", JSON.stringify(leavesPayload));
const res = await pachaFetch(
"/modules/family-calendar/includes/api/settings.php",
{ method: "POST", body: formData },
);
if (!res.success) throw new Error(res.error);
if (window.showToast) {
showToast(tr("fc_matrix_saved"), "success");
} else {
alert(tr("fc_matrix_saved"));
}
closeCalendarSettings();
setTimeout(openCalendarSettings, 300);
} catch (err) {
alert("Erreur : " + err.message);
}
}
// ============================================================================
// 2. MOTEUR PRINCIPAL DU CALENDRIER (DOM CONTENT LOADED)
// ============================================================================
document.addEventListener("DOMContentLoaded", () => {
// Récupération sécurisée depuis le nouveau format PHP
const parents = window.FAMILY_CONFIG?.parents || [];
const kids = window.FAMILY_CONFIG?.kids || [];
const activeCareModes = window.FAMILY_CONFIG?.activeCareModes || [];
const CONGE_TYPES = ["OFF_CAROLE", "EXTRA_OFF_CAROLE"];
const GUARDE_TYPES = ["CENTRE", "AVIS"];
const PEP_TYPES = ["PEP_SICK"];
// (Note: La logique LEAVES_CONFIG restera à basculer vers BDD dans un 2nd temps)
const LEAVES_CONFIG = {
CP: { startMonth: 8, defaultBalance: 25 },
JRA: {
yearlyTotals: { 2024: 10, 2025: 10, 2026: 11 },
defaultBalance: 10,
toleranceMonths: 2,
maxReport: 2,
},
JA: {},
};
parents.forEach((parent) => {
LEAVES_CONFIG.JA[parent.id] = {
startMonth: 4,
startDay: 29,
defaultBalance: 4,
};
});
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.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();
this.initSmartSelectors();
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 += `<option value="${y}" ${y === this.currentSchoolYearStart ? "selected" : ""}>${y} - ${y + 1}</option>`;
}
// Utilise la globale config ou fallback si non définie
const zoneText =
window.CONFIG?.ZONE_SCOLAIRE &&
window.CONFIG.ZONE_SCOLAIRE !== "Autre"
? `(Zone ${window.CONFIG.ZONE_SCOLAIRE})`
: "";
headerTitle.innerHTML = `🏖️ ${window.I18N?.fc_modal_holidays_title || "Vacances"} ${zoneText}
<select id="holidayYearSelect" class="pf-input" style="width:auto; display:inline-block; margin-left:10px; padding:4px 10px; height:auto;">
${options}
</select>`;
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,
balancesData,
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-balances.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.leaveBalances = balancesData.balances || [];
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() {
if (!this.schoolHolidaysTableBody) return;
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,
);
if (yearHolidays.length === 0) {
this.schoolHolidaysTableBody.innerHTML = `
<tr>
<td colspan="3" style="text-align:center; padding: 30px;">
<p style="color:#64748b; margin-bottom:15px;">${window.I18N?.fc_err_no_data_gov || "Aucune donnée"}</p>
<button id="btnFetchGovHolidays" class="pf-btn pf-btn-primary">Importer</button>
</td>
</tr>
`;
document
.getElementById("btnFetchGovHolidays")
?.addEventListener("click", (e) => {
e.target.innerText = "...";
e.target.disabled = true;
this.fetchAndSaveGovHolidays(this.modalSelectedYear);
});
return;
}
yearHolidays.sort((a, b) => new Date(a.date) - new Date(b.date));
const blocks = [];
let currentBlock = null;
yearHolidays.forEach((e) => {
const d = new Date(e.date + "T00:00:00");
if (!currentBlock) {
currentBlock = { start: d, end: d };
blocks.push(currentBlock);
} else {
const diffDays = Math.round(
(d.getTime() - currentBlock.end.getTime()) / 86400000,
);
if (diffDays <= 4) currentBlock.end = d;
else {
currentBlock = { start: d, end: d };
blocks.push(currentBlock);
}
}
});
this.schoolHolidaysTableBody.innerHTML = blocks
.map((block) => {
const m = block.start.getMonth() + 1;
const dur =
Math.round(
(block.end.getTime() - block.start.getTime()) / 86400000,
) + 1;
let name = window.I18N?.leg_school_holidays || "Vacances";
if (m === 10 || m === 11)
name = window.I18N?.vac_toussaint || "Toussaint";
else if (m === 12 || m === 1) name = window.I18N?.vac_noel || "Noël";
else if (m === 2 || m === 3) name = window.I18N?.vac_hiver || "Hiver";
else if (m === 4 || (m === 5 && dur > 6))
name = window.I18N?.vac_printemps || "Printemps";
else if (m === 5 && dur <= 6)
name = window.I18N?.vac_ascension || "Ascension";
else if (m === 7 || m === 8) name = window.I18N?.vac_ete || "Eté";
return `<tr>
<td><strong>${name}</strong></td>
<td>${block.start.toLocaleDateString(window.appLang || "fr-FR")}</td>
<td>${block.end.toLocaleDateString(window.appLang || "fr-FR")}</td>
</tr>`;
})
.join("");
}
async fetchAndSaveGovHolidays(yearStart) {
try {
const yearStr = `${yearStart}-${yearStart + 1}`;
const zone = window.CONFIG?.ZONE_SCOLAIRE || "C";
if (zone === "Autre") {
alert("Import auto dispo que pour Zones A, B ou C (France).");
return;
}
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 ${zone}%'&limit=100`;
const res = await fetch(url);
const data = await res.json();
const uniqueMap = new Map();
(data.results || []).forEach((r) =>
uniqueMap.set(`${r.description}|${r.start_date}`, r),
);
const payload = [];
Array.from(uniqueMap.values()).forEach((r) => {
let curr = new Date(r.start_date.split("T")[0] + "T00:00:00");
const end = new Date(r.end_date.split("T")[0] + "T00:00:00");
if (curr.getDay() === 5) curr.setDate(curr.getDate() + 1); // Règle: Décale vendredi -> samedi
while (curr < end) {
payload.push({
date: this.getLocalIsoDate(curr),
type: "VACANCES_SCOLAIRES",
duration: 1,
person: r.description,
});
curr.setDate(curr.getDate() + 1);
}
});
if (payload.length > 0) {
await this.postApi(
"/modules/family-calendar/includes/api/save-events.php",
payload,
);
await this.refreshAllData();
} else {
alert("Aucune donnée trouvée.");
}
} catch (e) {
alert("Erreur API Gouv.");
}
}
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;
let prefix = null;
if (parents[0] && parseInt(l.person_id) === parseInt(parents[0].id))
prefix = "alex";
else if (
parents[1] &&
parseInt(l.person_id) === parseInt(parents[1].id)
)
prefix = "laia";
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 = {};
parents.forEach((p) => {
balances[p.id] = { CP: {}, JRA: {}, JA: {} };
});
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);
});
parents.forEach((parent) => {
const pid = parent.id;
["CP", "JRA", "JA"].forEach((type) => {
ymList.forEach((ym) => {
const [currYear, currMonth] = ym.split("-").map(Number);
let cycleStartStr = "",
initialBalance = 0;
const latestSnapshot = (this.leaveSnapshots || [])
.filter(
(s) =>
s.person_id == pid &&
s.leave_type == type &&
s.snapshot_date.substring(0, 7) <= ym,
)
.sort((a, b) =>
b.snapshot_date.localeCompare(a.snapshot_date),
)[0];
if (latestSnapshot) {
cycleStartStr = latestSnapshot.snapshot_date.substring(0, 7);
initialBalance = parseFloat(latestSnapshot.remaining_balance);
} 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++)
usedPrevYear +=
usageByMonth[pid]?.[type]?.[
`${prevYear}-${String(m).padStart(2, "0")}`
] || 0;
initialBalance += Math.min(
Math.max(0, prevInitial - usedPrevYear),
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;
}
}
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)) : "";
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 = `<span class="fc-sticky-mois-label">${w.monthName}</span>`;
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 += `<span class="fc-pep-sick-emoji">🤒</span>`;
});
const dayLeaves = this.leaves.filter((l) => l.leave_date === iso);
if (dayLeaves.length) {
let html = `<div style="position:absolute; bottom:0; left:0; width:100%; font-size:9px; display:flex; justify-content:center; gap:2px; pointer-events:none;">`;
const colors = [
"#0f766e",
"#b45309",
"#047857",
"#4338ca",
"#b91c1c",
];
parents.forEach((person, index) => {
if (
dayLeaves.some(
(l) => parseInt(l.person_id) === parseInt(person.id),
)
) {
html += `<span style="color:${colors[index % colors.length]}; font-weight:800; margin: 0 1px;">${person.name.charAt(0).toUpperCase()}</span>`;
}
});
td.innerHTML += html + `</div>`;
}
tr.appendChild(td);
});
// --- 🟢 CORRECTION 1 : Colonnes du milieu (Modes de garde & Maladie) ---
const activeCareModes = window.FAMILY_CONFIG?.activeCareModes || [];
const kids = window.FAMILY_CONFIG?.kids || [];
// 1A. Modes de garde
activeCareModes.forEach((mode) => {
const td = document.createElement("td");
td.className = "col-total";
// On normalise le nom (ex: "Centre" -> "centre") pour matcher ton objet w.totals
const key = mode.toLowerCase();
td.textContent = fmt(w.totals[key] || 0);
tr.appendChild(td);
});
// 1B. Enfants (Maladie)
kids.forEach((kid) => {
const td = document.createElement("td");
td.className = "col-total";
// Rétrocompatibilité avec ton ancien code "pepSick"
const key =
kid.name.toLowerCase() === "pep"
? "pepSick"
: `${kid.name.toLowerCase()}Sick`;
td.textContent = fmt(w.totals[key] || 0);
tr.appendChild(td);
});
// --- 🟢 CORRECTION 2 : Colonnes des congés Parents (Dynamique !) ---
if (!processedLeavesCols[w.monthKey]) {
processedLeavesCols[w.monthKey] = true;
const parentsList = window.FAMILY_CONFIG?.parents || [];
parentsList.forEach((parent, index) => {
const cssPrefix = index % 2 === 0 ? "col-alex" : "col-laia";
// On récupère les compteurs réels du parent envoyés par le PHP
const parentLeaveTypes =
parent.leave_types && parent.leave_types.length > 0
? parent.leave_types
: ["CP", "JRA", "JA"];
parentLeaveTypes.forEach((type) => {
const info =
this.monthlyLeaveBalances[parent.id]?.[type]?.[w.monthKey];
tr.innerHTML += `<td class="${cssPrefix}-sub ${cssPrefix}-av" rowspan="${monthSpans[w.monthKey]}">${info ? fmt(info.availableAtMonthStart) : "-"}</td>`;
tr.innerHTML += `<td class="${cssPrefix}-sub ${cssPrefix}-use" rowspan="${monthSpans[w.monthKey]}">${info ? fmt(info.usedInMonth) : ""}</td>`;
});
});
}
this.planningBody.appendChild(tr);
});
}
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.renderMonthBalances();
this.syncSummaryWithMonth();
}
renderTwoMonthsView() {
const y = this.currentMonth.getFullYear(),
m = this.currentMonth.getMonth();
const nextDate = new Date(y, m + 1, 1);
const lang = window.I18N_LANG || "fr-FR";
this.monthCalendar.innerHTML = `<div class="fc-two-months-container">
<div class="fc-month-container"><div class="fc-month-title">${new Intl.DateTimeFormat(lang, { month: "long" }).format(this.currentMonth)}</div>${this.generateMonthHTML(y, m)}</div>
<div class="fc-month-container"><div class="fc-month-title">${new Intl.DateTimeFormat(lang, { month: "long" }).format(nextDate)}</div>${this.generateMonthHTML(nextDate.getFullYear(), nextDate.getMonth())}</div>
</div>`;
}
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 = `<div class="fc-three-months-container">`;
[d1, d2, d3].forEach(
(d) =>
(html += `<div class="fc-month-container"><div class="fc-month-title">${new Intl.DateTimeFormat("fr-FR", { month: "long" }).format(d)}</div>${this.generateMonthHTML(d.getFullYear(), d.getMonth())}</div>`),
);
this.monthCalendar.innerHTML = html + `</div>`;
}
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 = parents
.map((person) => {
let cards = `<div class="fc-minimal-balance-card"><strong class="col-alex">${person.name.toUpperCase()}</strong><div class="fc-minimal-chips">`;
["CP", "JRA", "JA"].forEach((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]);
if (endBal > 0) {
if (type === "CP" && cMonth >= 6 && cMonth <= 7)
alertHtml = `<div class="fc-burn-alert" title="Alerte perte">🔥</div>`;
else if (
type === "JRA" &&
(cMonth === 1 || cMonth === 2) &&
endBal > 2
)
alertHtml = `<div class="fc-burn-alert" title="Perte imminente">🔥</div>`;
}
cards += `<div class="fc-min-chip" title="Solde départ: ${fmt(startBal)}"><span class="type">${type}</span><span class="val">${fmt(endBal)}</span>${totalUsed > 0 ? `<span class="fc-used-badge">-${fmt(totalUsed)}</span>` : ""}${alertHtml}</div>`;
});
return cards + `</div></div>`;
})
.join("");
}
syncSummaryWithMonth() {
const typeSelect = document.getElementById("summType"),
valueSelect = document.getElementById("summValue");
if (typeSelect && valueSelect) {
if (typeSelect.value !== "month") {
typeSelect.value = "month";
typeSelect.dispatchEvent(new Event("change"));
}
valueSelect.value = `${this.currentMonth.getFullYear()}-${String(this.currentMonth.getMonth() + 1).padStart(2, "0")}`;
this.updateGlobalSummary();
}
}
generateMonthHTML(year, month) {
let html = `<table class="fc-month-table"><thead><tr>`;
["L", "M", "M", "J", "V"].forEach((d) => (html += `<th>${d}</th>`));
html += `</tr></thead><tbody><tr>`;
const daysInMonth = new Date(year, month + 1, 0).getDate();
let currentRenderedCols = 0,
startDay = (new Date(year, month, 1).getDay() + 6) % 7;
if (startDay < 5) {
for (let i = 0; i < startDay; i++) {
html += `<td class="fc-day--other-month"></td>`;
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 += `</tr><tr>`;
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 === "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";
let content = `<div style="position:relative; height:100%;"><span class="fc-day-number">${d}</span>`;
if (dayEvts.some((e) => e.type === "PEP_SICK"))
content += `<span style="position:absolute; bottom:2px; right:2px;">🤒</span>`;
const dayLeaves = this.leaves.filter((l) => l.leave_date === iso);
if (dayLeaves.length) {
content += `<div style="position:absolute; bottom:2px; left:2px; font-size:10px; font-weight:bold;">`;
parents.forEach((parent, index) => {
if (
dayLeaves.some(
(l) => parseInt(l.person_id) === parseInt(parent.id),
)
) {
content += `<span style="color:${index % 2 === 0 ? "#0f766e" : "#b45309"}">${parent.name.charAt(0).toUpperCase()}</span> `;
}
});
content += `</div>`;
}
html += `<td class="${cls}" data-date="${iso}">${content}</div></td>`;
currentRenderedCols++;
}
while (currentRenderedCols < 5 && currentRenderedCols > 0) {
html += `<td class="fc-day--other-month"></td>`;
currentRenderedCols++;
}
return html + `</tr></tbody></table>`;
}
initSummaryControls() {
const typeSelect = document.getElementById("summType"),
valueSelect = document.getElementById("summValue");
if (!typeSelect || !valueSelect) return;
const years = new Set(),
months = new Set();
this.weeks.forEach((w) =>
Object.values(w.dayDates).forEach((d) => {
years.add(d.getFullYear());
months.add(
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
);
}),
);
const populateValues = () => {
const currentType = typeSelect.value,
prevValue = valueSelect.value;
valueSelect.innerHTML = "";
if (currentType === "year") {
Array.from(years)
.sort()
.forEach((y) => valueSelect.add(new Option(y, y)));
valueSelect.value = years.has(parseInt(prevValue))
? prevValue
: new Date().getFullYear();
} else {
Array.from(months)
.sort()
.forEach((m) => {
const [y, mo] = m.split("-");
valueSelect.add(
new Option(
new Intl.DateTimeFormat("fr-FR", {
month: "long",
year: "numeric",
}).format(new Date(y, mo - 1, 1)),
m,
),
);
});
const nowIso = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
if (months.has(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"),
typeSelect = document.getElementById("summType"),
valueSelect = document.getElementById("summValue");
if (!div || !valueSelect?.value) return;
const filterType = typeSelect.value,
filterValue = valueSelect.value;
const stats = { off: 0, extra: 0, sick: 0, pep: 0 };
this.weeks.forEach((w) => {
Object.values(w.dayDates).forEach((dateObj) => {
if (
(filterType === "year" &&
dateObj.getFullYear().toString() === filterValue) ||
(filterType === "month" &&
this.getLocalIsoDate(dateObj).slice(0, 7) === filterValue)
) {
const dayEvents = this.events.filter(
(e) => e.date === this.getLocalIsoDate(dateObj),
);
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;
});
if (!this.publicHolidayDates.has(this.getLocalIsoDate(dateObj))) {
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);
}
}
});
});
const tr = window.I18N || {};
div.innerHTML = `
<div class="fc-summary-item"><span class="fc-summary-label">${tr.leg_off_carole || "Off"}</span><span class="fc-summary-value">${parseFloat(stats.off.toFixed(1))} j</span></div>
<div class="fc-summary-item"><span class="fc-summary-label">${tr.leg_extra_off || "Extra"}</span><span class="fc-summary-value">${parseFloat(stats.extra.toFixed(1))} j</span></div>
<div class="fc-summary-item"><span class="fc-summary-label">${tr.leg_pep_sick || "Maladie"}</span><span class="fc-summary-value">${parseFloat(stats.sick.toFixed(1))} j</span></div>
<div class="fc-summary-item"><span class="fc-summary-label">${tr.leg_presence || "Présence"}</span><span class="fc-summary-value">${parseFloat(stats.pep.toFixed(1))} j</span></div>
`;
}
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 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() {
// -- BRANCHEMENT MODALE DE CONFIGURATION (NOUVEAU) --
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);
}
});
}
const btnOpen = document.getElementById("btnOpenHolidays"),
btnClose = document.getElementById("btnCloseHolidays"),
modal = document.getElementById("modalHolidays");
if (btnOpen && modal)
btnOpen.addEventListener("click", () => {
modal.classList.add("open");
document.body.classList.add("no-scroll");
this.renderModalHolidays();
});
if (btnClose && modal)
btnClose.addEventListener("click", () => {
modal.classList.remove("open");
document.body.classList.remove("no-scroll");
});
if (modal)
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.classList.remove("open");
document.body.classList.remove("no-scroll");
}
});
const btnSnap = document.getElementById("btnOpenSnapshotModal"),
modalSnap = document.getElementById("modalSnapshot"),
btnCloseSnap = document.getElementById("btnCloseSnapshot"),
formSnap = document.getElementById("formSnapshot");
if (btnSnap && modalSnap)
btnSnap.addEventListener("click", () => {
modalSnap.classList.add("open");
document.body.classList.add("no-scroll");
const snapDateInput = document.getElementById("snapDate");
if (snapDateInput)
snapDateInput.value = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}-01`;
});
if (btnCloseSnap && modalSnap)
btnCloseSnap.addEventListener("click", () => {
modalSnap.classList.remove("open");
document.body.classList.remove("no-scroll");
});
if (modalSnap)
modalSnap.addEventListener("click", (e) => {
if (e.target === modalSnap) {
modalSnap.classList.remove("open");
document.body.classList.remove("no-scroll");
}
});
if (formSnap)
formSnap.addEventListener("submit", async (e) => {
e.preventDefault();
try {
await this.postApi(
"/modules/family-calendar/includes/api/save-leave-snapshot.php",
{
person_id: document.getElementById("snapPerson").value,
leave_type: document.getElementById("snapType").value,
snapshot_date: document.getElementById("snapDate").value,
remaining_balance: document.getElementById("snapBalance").value,
},
);
modalSnap.classList.remove("open");
document.body.classList.remove("no-scroll");
formSnap.reset();
await this.refreshAllData();
} catch (error) {
alert("Erreur lors de la sauvegarde.");
}
});
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();
});
if (this.monthCalendar) {
let startX = 0,
startY = 0;
this.monthCalendar.addEventListener(
"touchstart",
(e) => {
startX = e.changedTouches[0].screenX;
startY = e.changedTouches[0].screenY;
},
{ passive: true },
);
this.monthCalendar.addEventListener(
"touchend",
(e) => {
const dX = e.changedTouches[0].screenX - startX,
dY = e.changedTouches[0].screenY - startY;
if (Math.abs(dX) > Math.abs(dY) && Math.abs(dX) > 50) {
const num =
this.viewMode === "2months"
? 2
: this.viewMode === "3months"
? 3
: 1;
this.currentMonth.setMonth(
this.currentMonth.getMonth() + (dX < 0 ? num : -num),
);
this.renderMonthCalendar();
}
},
{ passive: true },
);
}
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 activeEvents = new Set();
this.events.forEach((e) => {
if (dates.includes(e.date)) activeEvents.add(e.type);
});
const activeLeaves = {};
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 getBtnClass = (type, pid = null) =>
(pid ? activeLeaves[pid]?.has(type) : activeEvents.has(type))
? "fc-menu-btn--active"
: "";
const getBtnIcon = (type, pid = null) =>
(pid ? activeLeaves[pid]?.has(type) : activeEvents.has(type))
? "✓ "
: "";
const tr = window.I18N || {};
const dateLabel =
dates.length > 1
? `${dates.length} Jours`
: new Date(dates[0]).toLocaleDateString(window.I18N_LANG || "fr-FR");
const trashSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>`;
const buildHeader = (title, action, cat) =>
`<div class="fc-menu-header"><strong>${title}</strong>${action ? `<button class="fc-menu-clear-icon" data-action="${action}" ${cat ? `data-cat="${cat}"` : ""}>${trashSvg}</button>` : ""}</div>`;
let html = `<div class="fc-menu-section" style="border-bottom: none; padding-bottom: 0;"><strong style="font-size:0.85rem;">${dateLabel}</strong></div>`;
html += `<div class="fc-menu-section">${buildHeader(tr.fc_menu_carole || "Carole", "clear-type", "CONGE")}<div class="fc-menu-grid"><button class="fc-menu-btn ${getBtnClass("OFF_CAROLE")}" data-action="add" data-type="OFF_CAROLE">${getBtnIcon("OFF_CAROLE")}${tr.btn_off || "OFF"}</button><button class="fc-menu-btn ${getBtnClass("EXTRA_OFF_CAROLE")}" data-action="add" data-type="EXTRA_OFF_CAROLE">${getBtnIcon("EXTRA_OFF_CAROLE")}${tr.btn_extra || "EXTRA"}</button></div></div>`;
html += `<div class="fc-menu-section">${buildHeader(tr.leg_centre || "Centre", "clear-type", "GARDE")}<div class="fc-menu-grid"><button class="fc-menu-btn ${getBtnClass("CENTRE")}" data-action="add" data-type="CENTRE">${getBtnIcon("CENTRE")}${tr.leg_centre || "Centre"}</button><button class="fc-menu-btn ${getBtnClass("AVIS")}" data-action="add" data-type="AVIS">${getBtnIcon("AVIS")}${tr.leg_avis || "Avis"}</button></div></div>`;
html += `<div class="fc-menu-section">${buildHeader("Pep", "clear-type", "PEP")}<button class="fc-menu-btn ${getBtnClass("PEP_SICK")}" data-action="add" data-type="PEP_SICK" style="width:100%">${getBtnIcon("PEP_SICK")}${tr.leg_pep_sick || "Maladie"} 🤒</button></div>`;
html += `<div class="fc-menu-section">${buildHeader(tr.fc_menu_kids_leaves || "Congés Parents", null, null)}<div class="fc-menu-leaves-table"><table><thead><tr>`;
parents.forEach(
(p) =>
(html += `<th><div class="fc-th-inline">${p.name} <button class="fc-menu-clear-icon fc-menu-btn-th" data-action="clear-leaves-person" data-pid="${p.id}">${trashSvg}</button></div></th>`),
);
html += `</tr></thead><tbody>`;
["CP", "JRA", "JA"].forEach((t) => {
html += `<tr>`;
parents.forEach(
(p) =>
(html += `<td><button class="fc-menu-btn ${getBtnClass(t, p.id)}" data-action="add-leave" data-pid="${p.id}" data-type="${t}">${getBtnIcon(t, p.id)}${t}</button></td>`),
);
html += `</tr>`;
});
html += `</tbody></table></div></div>`;
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, pid, cat } = dataset,
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 },
);
await this.postApi(
"/modules/family-calendar/includes/api/save-events.php",
dates.map((d) => ({
date: d,
type: type,
duration: 1,
person: "Carole",
})),
);
} else if (action === "clear-type") {
let typesToClear =
cat === "CONGE"
? CONGE_TYPES
: cat === "GARDE"
? GUARDE_TYPES
: cat === "PEP"
? 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),
},
);
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();
});