From f19a7442deab895aac79361cdbb1420a1074f644 Mon Sep 17 00:00:00 2001 From: "fefe.clochette" Date: Tue, 18 Aug 2026 16:58:09 +0200 Subject: [PATCH] arrange leave type --- docker/schema_family.sql | 13 +- family-calendar.php | 44 +- includes/lang/ca.php | 15 + includes/lang/en.php | 15 + includes/lang/fr.php | 15 + migrate.php | 73 +-- modules/family-calendar/family-calendar.js | 472 ++++++++++++++---- .../includes/api/calendar-settings.php | 50 +- .../includes/api/get-leave-snapshots.php | 2 +- .../includes/api/save-leave-snapshot.php | 7 +- 10 files changed, 538 insertions(+), 168 deletions(-) diff --git a/docker/schema_family.sql b/docker/schema_family.sql index 573cf2e..f28caa5 100644 --- a/docker/schema_family.sql +++ b/docker/schema_family.sql @@ -88,7 +88,8 @@ CREATE TABLE IF NOT EXISTS pf_leave_snapshots ( person_id INT NOT NULL, leave_type VARCHAR(50) NOT NULL, snapshot_date DATE NOT NULL, - remaining_balance DECIMAL(6,2) DEFAULT 0 + remaining_balance DECIMAL(6,2) DEFAULT 0.00, + carry_over_balance DECIMAL(6,2) DEFAULT 0.00 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS pf_person_leave_meta ( @@ -97,9 +98,12 @@ CREATE TABLE IF NOT EXISTS pf_person_leave_meta ( leave_type VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL, method VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'FIXED', allowance DECIMAL(5,2) DEFAULT 0.00, + carry_over_max_days DECIMAL(5,2) DEFAULT 0.00, + carry_over_deadline_month INT(11) DEFAULT 12, anniversary_date DATE DEFAULT NULL, INDEX (person_id), - INDEX (leave_type) + INDEX (leave_type), + UNIQUE KEY uq_person_leave (person_id, leave_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ─── Types de Congés (Référentiel) ───────────────────────────────────────── @@ -109,12 +113,11 @@ CREATE TABLE IF NOT EXISTS pf_leave_types ( label VARCHAR(100) COLLATE utf8mb4_general_ci NOT NULL, default_allowance DECIMAL(5,2) DEFAULT 0.00, reset_month INT(11) DEFAULT 1, - allow_carry_over TINYINT(1) DEFAULT 0, UNIQUE KEY code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -INSERT IGNORE INTO pf_leave_types (code, label, default_allowance, reset_month, allow_carry_over) VALUES -('CP', 'Congés Payés', 25.00, 6, 0); +INSERT IGNORE INTO pf_leave_types (code, label, default_allowance, reset_month) VALUES +('CP', 'Congés Payés', 25.00, 6); CREATE TABLE IF NOT EXISTS pf_calendar_weeks ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/family-calendar.php b/family-calendar.php index 46b445b..5e5eaa3 100644 --- a/family-calendar.php +++ b/family-calendar.php @@ -185,18 +185,33 @@ require __DIR__ . '/header.php';
- + +
+ +
+ +
+ + + + + +
+ +
+ + + - @@ -289,6 +304,25 @@ require __DIR__ . '/header.php'; +
+
+ + +
+
+ + +
+
+
diff --git a/includes/lang/ca.php b/includes/lang/ca.php index e644bc3..818e080 100644 --- a/includes/lang/ca.php +++ b/includes/lang/ca.php @@ -280,6 +280,21 @@ return [ 'btn_save_rights' => 'Guardar', + 'fc_label_carry_over' => 'Inclou dies reportats (Caducables) ⚠️', + 'fc_carry_over_note' => 'Part del saldo global que caducarà a la data límit.', + 'fc_carry_max' => 'Màx Reportable', + 'fc_carry_deadline' => 'Mes de Caducitat', + 'fc_deadline_none' => 'Cap', + 'fc_allowance_help' => 'Fix: Quota anual. Gradual: Afegit per mes.', + 'fc_carry_max_short' => 'Report màx', + 'fc_edit_leave' => '✏️ Modificar:', + 'fc_not_modifiable' => 'No modificable', + 'fc_add_leave' => '+ Afegir', + 'fc_err_code_label' => 'El Codi i l\'Etiqueta són obligatoris.', + 'fc_catalog_updated' => 'Catàleg actualitzat!', + 'fc_balance_corrected' => 'Saldo corregit!', + 'fc_no_leave_configured' => 'Cap permís configurat.', + // ========================================== // MODAL DE CONFIGURACIÓ DEL CALENDARI // ========================================== diff --git a/includes/lang/en.php b/includes/lang/en.php index ed7391b..7742f82 100644 --- a/includes/lang/en.php +++ b/includes/lang/en.php @@ -281,6 +281,21 @@ return [ 'btn_save_rights' => 'Save', + 'fc_label_carry_over' => 'Incl. carried over days (Expiring) ⚠️', + 'fc_carry_over_note' => 'Portion of the overall balance that will expire on the deadline.', + 'fc_carry_max' => 'Max Carry Over', + 'fc_carry_deadline' => 'Expiration Month', + 'fc_deadline_none' => 'None', + 'fc_allowance_help' => 'Fixed: Annual quota. Accrued: Added per month.', + 'fc_carry_max_short' => 'Max carry-over', + 'fc_edit_leave' => '✏️ Edit:', + 'fc_not_modifiable' => 'Not modifiable', + 'fc_add_leave' => '+ Add', + 'fc_err_code_label' => 'Code and Label are required.', + 'fc_catalog_updated' => 'Catalog updated!', + 'fc_balance_corrected' => 'Balance corrected!', + 'fc_no_leave_configured' => 'No leave configured.', + // ========================================== // CALENDAR SETTINGS MODAL // ========================================== diff --git a/includes/lang/fr.php b/includes/lang/fr.php index 0398ce8..120ca40 100644 --- a/includes/lang/fr.php +++ b/includes/lang/fr.php @@ -283,6 +283,21 @@ return [ 'btn_save_rights' => 'Enregistrer', + 'fc_label_carry_over' => 'Dont jours reportés (Périssables) ⚠️', + 'fc_carry_over_note' => 'Partie du solde global qui expirera à la date butoir définie.', + 'fc_carry_max' => 'Max Reportable', + 'fc_carry_deadline' => 'Mois Expiration', + 'fc_deadline_none' => 'Aucune', + 'fc_allowance_help' => 'Fixe: Quota annuel. Graduel: Ajout par mois.', + 'fc_carry_max_short' => 'Report max', + 'fc_edit_leave' => '✏️ Modifier :', + 'fc_not_modifiable' => 'Non modifiable', + 'fc_add_leave' => '+ Ajouter', + 'fc_err_code_label' => 'Le code et le libellé sont obligatoires.', + 'fc_catalog_updated' => 'Catalogue mis à jour !', + 'fc_balance_corrected' => 'Solde corrigé !', + 'fc_no_leave_configured' => 'Aucun congé configuré.', + // ========================================== // MODALE PARAMÈTRES CALENDRIER // ========================================== diff --git a/migrate.php b/migrate.php index 18aec7f..619169b 100644 --- a/migrate.php +++ b/migrate.php @@ -1,48 +1,51 @@ 🛠️ HouseHub - Correction AUTO_INCREMENT Voyages"; +// Détection stricte de l'environnement (Docker vs XAMPP) +$envHost = getenv('DB_HOST'); +if ($envHost === 'househub-db') { + $host = 'househub-db'; + $user = getenv('DB_USER') ?: 'househub'; + $pass = getenv('DB_PASS') ?: 'changeme'; +} else { + $host = '127.0.0.1'; + $user = 'root'; + $pass = ''; +} try { - // On récupère toutes les familles actives - $stmt = $meta_pdo->query("SELECT db_name, name FROM families"); + $stmt = $meta_pdo->query("SELECT db_name FROM families"); $families = $stmt->fetchAll(PDO::FETCH_ASSOC); - foreach ($families as $f) { - $dbName = $f['db_name']; - echo "

Famille : {$f['name']} ($dbName)

"; - } - echo "

🚀 Correction terminée ! Tu peux réessayer de créer un voyage.

"; -} catch (Exception $e) { - die("Erreur fatale de connexion à la Meta DB : " . $e->getMessage()); + echo "✅ Migration réussie pour $db.

"; + } + + echo "🎉 Toutes les bases ont été mises à jour ! Vous pouvez supprimer ce fichier."; + +} catch (PDOException $e) { + die("❌ Erreur fatale : " . $e->getMessage()); } ?> \ No newline at end of file diff --git a/modules/family-calendar/family-calendar.js b/modules/family-calendar/family-calendar.js index b0e29e9..d009465 100644 --- a/modules/family-calendar/family-calendar.js +++ b/modules/family-calendar/family-calendar.js @@ -198,20 +198,21 @@ function renderLeaveCatalog(types) { container.innerHTML = ""; if (types.length === 0) { - container.innerHTML = `

Aucun congé configuré.

`; + container.innerHTML = `

${tr("fc_no_leave_configured") || "Aucun congé configuré."}

`; return; } types.forEach((lt) => { + // Fini l'injection des variables de report ici, elles n'existent plus au niveau global ! container.innerHTML += ` -
+
- ${lt.label} - ${lt.code} + ${lt.label} + ${lt.code}
-
- - +
+ +
`; @@ -220,7 +221,7 @@ function renderLeaveCatalog(types) { function editLeaveType(code, label) { document.getElementById("leaveTypeFormTitle").innerText = - "✏️ Modifier : " + label; + (tr("fc_edit_leave") || "✏️ Modifier :") + " " + label; document.getElementById("lt-mode").value = "edit"; const codeInput = document.getElementById("lt-code"); @@ -230,12 +231,14 @@ function editLeaveType(code, label) { codeInput.style.opacity = "0.6"; codeInput.style.cursor = "not-allowed"; - document.getElementById("lt-code-note").innerText = "Non modifiable"; + document.getElementById("lt-code-note").innerText = + tr("fc_not_modifiable") || "Non modifiable"; document.getElementById("lt-label").value = label; } function resetLeaveTypeForm() { - document.getElementById("leaveTypeFormTitle").innerText = "+ Ajouter"; + document.getElementById("leaveTypeFormTitle").innerText = + tr("fc_add_leave") || "+ Ajouter"; document.getElementById("lt-mode").value = "add"; const codeInput = document.getElementById("lt-code"); @@ -245,7 +248,8 @@ function resetLeaveTypeForm() { codeInput.style.opacity = "1"; codeInput.style.cursor = "text"; - document.getElementById("lt-code-note").innerText = "Irréversible"; + document.getElementById("lt-code-note").innerText = + tr("fc_code_irreversible") || "Irréversible"; document.getElementById("lt-label").value = ""; } @@ -283,17 +287,21 @@ async function deleteLeaveType(code, label) { async function saveLeaveType() { const code = document.getElementById("lt-code").value.toUpperCase(); const label = document.getElementById("lt-label").value; + const carryMax = document.getElementById("lt-carry-max").value; + const carryDeadline = document.getElementById("lt-carry-deadline").value; if (!code || !label) return window.showToast - ? showToast("Code et Label requis", "error") - : alert("Code et Label requis"); + ? showToast(tr("fc_err_code_label"), "error") + : alert(tr("fc_err_code_label")); const fd = new FormData(); fd.append("action", "save_leave_type"); fd.append("mode", document.getElementById("lt-mode").value); fd.append("code", code); fd.append("label", label); + fd.append("carry_over_max_days", carryMax); + fd.append("carry_over_deadline_month", carryDeadline); try { const res = await pachaFetch( @@ -302,11 +310,11 @@ async function saveLeaveType() { ); if (!res.success) throw new Error(res.error); - if (window.showToast) showToast("Catalogue mis à jour !", "success"); + if (window.showToast) showToast(tr("fc_catalog_updated"), "success"); resetLeaveTypeForm(); loadLeaveCatalog(); } catch (err) { - alert("Erreur d'enregistrement : " + err.message); + alert(tr("error_occured") + " " + err.message); } } @@ -373,84 +381,194 @@ async function loadMemberConfigView() { function renderMemberLeavesView(personId, memberLeaves) { const zone = document.getElementById("memberConfigZone"); - let html = `
Congés attribués
`; + let html = `
Congés attribués
`; if (memberLeaves.length === 0) { - html += `

Aucun congé attribué à ce membre.

`; + html += `

Aucun congé attribué à ce membre.

`; } else { - html += `
`; + html += `
`; memberLeaves.forEach((ml) => { const moisRenouv = ml.anniversary_date ? parseInt(ml.anniversary_date.split("-")[1]) : 1; - const methodeLabel = ml.method === "ACCUMULATED" ? "Graduel" : "Fixe"; + const isGraduel = ml.method === "ACCUMULATED"; + const methodeLabel = isGraduel ? "Graduel" : "Fixe"; + + // Construction des petits Badges + let badgesHtml = ` + + ⚖️ ${ml.allowance} j ${isGraduel ? "/ mois" : "/ an"} + + + 🔄 Mois ${moisRenouv} + + `; + + if (ml.carry_over_max_days > 0) { + badgesHtml += ` + + ⚠️ Report Max: ${ml.carry_over_max_days}j (Exp. M${ml.carry_over_deadline_month}) + `; + } html += ` -
-
- ${ml.leave_type} - - (Quota: ${ml.allowance}j - Renouv: Mois ${moisRenouv} - Acquis: ${methodeLabel}) - +
+
+ ${ml.leave_type} — ${methodeLabel} +
+ ${badgesHtml} +
+
+
+ +
-
`; }); html += `
`; } + // --- LE FORMULAIRE RESTRUCTURÉ EN GRILLE --- html += ` -
-
+ Attribuer un congé
-
-
- - + +
+
+ +
-
- - -
-
- - + +
-
- - +
+ +
+ +
- +
+ + +
+

⚙️ Règles de report (Optionnel)

+
+
+ + +
+
+ + +
+
+
+ +
+ +
`; zone.innerHTML = html; } +// === NOUVELLES FONCTIONS : ÉDITION ET RESET === +window.editMemberLeave = function ( + id, + type, + allowance, + resetMonth, + method, + carryMax, + carryDeadline, +) { + document.getElementById("formMemberLeaveTitle").innerText = + "✏️ Modifier le congé"; + document.getElementById("new-member-leave-id").value = id; + + const typeSelect = document.getElementById("new-member-leave-type"); + typeSelect.value = type; + typeSelect.disabled = true; // On bloque le type car il définit la clé unique + typeSelect.style.backgroundColor = "var(--bg-subtle)"; + + document.getElementById("new-member-leave-allowance").value = allowance; + document.getElementById("new-member-leave-reset").value = resetMonth; + document.getElementById("new-member-leave-method").value = method; + document.getElementById("new-member-leave-carry-max").value = carryMax || 0; + document.getElementById("new-member-leave-carry-deadline").value = + carryDeadline || 12; + + document.getElementById("btn-submit-member-leave").innerText = + tr("btn_save") || "Enregistrer"; + document.getElementById("btn-cancel-member-leave").style.display = + "inline-flex"; +}; + +window.resetMemberLeaveForm = function () { + document.getElementById("formMemberLeaveTitle").innerText = + "+ Attribuer un congé"; + document.getElementById("new-member-leave-id").value = ""; + + const typeSelect = document.getElementById("new-member-leave-type"); + typeSelect.value = ""; + typeSelect.disabled = false; + typeSelect.style.backgroundColor = ""; + + document.getElementById("new-member-leave-allowance").value = "0"; + document.getElementById("new-member-leave-reset").value = "1"; + document.getElementById("new-member-leave-method").value = "FIXED"; + document.getElementById("new-member-leave-carry-max").value = "0"; + document.getElementById("new-member-leave-carry-deadline").value = "12"; + + document.getElementById("btn-submit-member-leave").innerText = "Ajouter"; + document.getElementById("btn-cancel-member-leave").style.display = "none"; +}; + async function addMemberLeave(personId) { - const leaveCode = document.getElementById("new-member-leave-type").value; + const id = document.getElementById("new-member-leave-id").value; + const typeSelect = document.getElementById("new-member-leave-type"); + const leaveCode = typeSelect.value; const allowance = document.getElementById("new-member-leave-allowance").value; const resetMonth = document.getElementById("new-member-leave-reset").value; const method = document.getElementById("new-member-leave-method").value; + const carryMax = document.getElementById("new-member-leave-carry-max").value; + const carryDeadline = document.getElementById( + "new-member-leave-carry-deadline", + ).value; if (!leaveCode) { return window.showToast @@ -460,11 +578,14 @@ async function addMemberLeave(personId) { const fd = new FormData(); fd.append("action", "add_person_leave"); + fd.append("meta_id", id); fd.append("person_id", personId); fd.append("leave_type", leaveCode); fd.append("allowance", allowance); fd.append("reset_month", resetMonth); fd.append("method", method); + fd.append("carry_over_max_days", carryMax); + fd.append("carry_over_deadline_month", carryDeadline); try { const res = await pachaFetch( @@ -473,11 +594,46 @@ async function addMemberLeave(personId) { ); if (!res.success) throw new Error(res.error); - if (window.showToast) showToast("Congé attribué avec succès !", "success"); + if (window.showToast) showToast("Opération réussie !", "success"); loadMemberConfigView(); } catch (err) { + if (window.showToast) showToast("Erreur : " + err.message, "error"); + else alert("Erreur: " + err.message); + } +} + +async function deleteMemberLeave(id, personId) { + if ( + !confirm( + tr("confirm_delete") || + "Voulez-vous vraiment retirer ce congé pour ce membre ?", + ) + ) + return; + + const fd = new FormData(); + fd.append("action", "delete_person_leave"); + fd.append("id", id); + + try { + const res = await pachaFetch( + "/modules/family-calendar/includes/api/calendar-settings.php", + { method: "POST", body: fd }, + ); + if (!res.success) throw new Error(res.error); + if (window.showToast) - showToast("Erreur lors de l'attribution : " + err.message, "error"); + showToast( + tr("fc_settings_updated") || "Congé retiré avec succès !", + "success", + ); + loadMemberConfigView(); // Recharge la vue des attributions du membre + } catch (err) { + if (window.showToast) + showToast( + (tr("error_occured") || "Erreur") + " : " + err.message, + "error", + ); else alert("Erreur: " + err.message); } } @@ -916,6 +1072,7 @@ document.addEventListener("DOMContentLoaded", () => { const balances = {}; this.parents.forEach((p) => (balances[String(p.id)] = {})); + // 1. Préparation d'une chronologie glissante (60 mois) const ymList = []; const tempDate = new Date(); tempDate.setFullYear(tempDate.getFullYear() - 2); @@ -926,6 +1083,7 @@ document.addEventListener("DOMContentLoaded", () => { tempDate.setMonth(tempDate.getMonth() + 1); } + // 2. Indexation des jours consommés par mois const usageByMonth = {}; const allPlacedLeaves = [...(this.leaves || []), ...(this.events || [])]; @@ -947,6 +1105,7 @@ document.addEventListener("DOMContentLoaded", () => { usageByMonth[pid][type][ym] = (usageByMonth[pid][type][ym] || 0) + dur; }); + // 3. Parcours Chronologique this.parents.forEach((parent) => { const pid = String(parent.id); const matrix = @@ -958,49 +1117,74 @@ document.addEventListener("DOMContentLoaded", () => { .toUpperCase(); if (!balances[pid][type]) balances[pid][type] = {}; - let monthRenouvellement = 1; - const dateVal = conf.anniversary_date || conf.date; - if (dateVal && dateVal.includes("-")) { - monthRenouvellement = parseInt(dateVal.split("-")[1], 10) || 1; - } + // Paramètres du congé + const resetMonth = parseInt( + conf.date ? conf.date.split("-")[1] : 1, + 10, + ); + const maxCarry = parseFloat(conf.carry_over_max_days || 0); + const deadlineMonth = parseInt( + conf.carry_over_deadline_month || 12, + 10, + ); + const method = conf.method || "FIXED"; + const allowance = parseFloat(conf.allowance || 0); - let initialBalance = parseFloat(conf.allowance); - if (isNaN(initialBalance)) initialBalance = 0; + let mainBalance = 0; + let carryBalance = 0; + + // Filtrer les snapshots de cette personne et de ce congé + const snapshots = (this.leaveSnapshots || []).filter( + (s) => String(s.person_id) === pid && s.leave_type === type, + ); ymList.forEach((ym) => { - const [currYearStr, currMonthStr] = ym.split("-"); - const currYear = parseInt(currYearStr, 10); - const currMonth = parseInt(currMonthStr, 10); + const currM = parseInt(ym.split("-")[1], 10); + const snap = snapshots.find((s) => s.snapshot_date.startsWith(ym)); - const isPastAnniversary = currMonth >= monthRenouvellement; - const refYear = isPastAnniversary ? currYear : currYear - 1; - const cycleStartStr = `${refYear}-${String(monthRenouvellement).padStart(2, "0")}`; + if (snap) { + // A. LE MUR DE BÉTON : Le Snapshot écrase tout le passé + mainBalance = parseFloat(snap.remaining_balance || 0); + carryBalance = parseFloat(snap.carry_over_balance || 0); + } else { + // B. LA GUILLOTINE : Effacement du report au mois M+1 de la deadline + let wipeMonth = (deadlineMonth % 12) + 1; + if (currM === wipeMonth && maxCarry > 0) { + carryBalance = 0; + } - let acquiredBalance = initialBalance; - if (conf.method === "ACCUMULATED") { - let monthsPassed = - (currYear - refYear) * 12 + - (currMonth - monthRenouvellement) + - 1; - acquiredBalance = Math.min( - initialBalance, - (initialBalance / 12) * monthsPassed, - ); + // C. RENOUVELLEMENT ANNUEL (Bascule vers le Report) + if (currM === resetMonth) { + carryBalance = Math.min(mainBalance, maxCarry); + mainBalance = 0; + } + + // D. ACQUISITION + if (method === "FIXED" && currM === resetMonth) { + mainBalance += allowance; + } else if (method === "ACCUMULATED") { + // Acquisition graduelle stricte (ex: +0.64/mois) + mainBalance += allowance; + } } - let usedBeforeCurrentMonth = 0; - Object.keys(usageByMonth[pid]?.[type] || {}).forEach((usedYm) => { - if (usedYm >= cycleStartStr && usedYm < ym) { - usedBeforeCurrentMonth += usageByMonth[pid][type][usedYm]; - } - }); + // E. CONSOMMATION DU MOIS + const totalAvailableAtStart = mainBalance + carryBalance; + const carryAtStart = carryBalance; + const usedInMonth = usageByMonth[pid]?.[type]?.[ym] || 0; + // Priorité absolue : On tape d'abord dans les jours périssables ! + let usedFromCarry = Math.min(usedInMonth, carryBalance); + carryBalance -= usedFromCarry; + + let usedFromMain = usedInMonth - usedFromCarry; + mainBalance -= usedFromMain; + + // F. SAUVEGARDE DE L'ÉTAT DU MOIS balances[pid][type][ym] = { - availableAtMonthStart: Math.max( - 0, - acquiredBalance - usedBeforeCurrentMonth, - ), - usedInMonth: usageByMonth[pid]?.[type]?.[ym] || 0, + availableAtMonthStart: totalAvailableAtStart, + carryAtStart: carryAtStart, + usedInMonth: usedInMonth, }; }); }); @@ -1454,10 +1638,15 @@ document.addEventListener("DOMContentLoaded", () => { const type = String(conf.leave_type || conf.type) .trim() .toUpperCase(); + const startBal = this.monthlyLeaveBalances[String(person.id)]?.[type]?.[ monthsToDisplay[0] ]?.availableAtMonthStart || 0; + const carryAtStart = + this.monthlyLeaveBalances[String(person.id)]?.[type]?.[ + monthsToDisplay[0] + ]?.carryAtStart || 0; let totalUsed = 0; monthsToDisplay.forEach((ym) => { @@ -1467,20 +1656,37 @@ document.addEventListener("DOMContentLoaded", () => { }); const endBal = Math.max(0, startBal - totalUsed); + const remainingCarry = Math.max(0, carryAtStart - totalUsed); const fmt = (n) => - n > 0 ? (Number.isInteger(n) ? n : n.toFixed(1)) : "0"; + n > 0 ? (Number.isInteger(n) ? n : n.toFixed(2)) : "0"; let alertHtml = ""; const cMonth = parseInt(monthsToDisplay[0].split("-")[1], 10); - const dateVal = conf.anniversary_date || conf.date; - if (endBal > 0 && dateVal && dateVal.includes("-")) { - const resetMonth = parseInt(dateVal.split("-")[1], 10) || 1; - const alertMonth = resetMonth === 1 ? 12 : resetMonth - 1; + const resetMonth = parseInt( + conf.date ? conf.date.split("-")[1] : 1, + 10, + ); + const deadline = parseInt(conf.carry_over_deadline_month || 12, 10); - if (cMonth === alertMonth || cMonth === resetMonth) { - alertHtml = `
🔥
`; - } + const alertMonthEndCycle = resetMonth === 1 ? 12 : resetMonth - 1; + + // ⚠️ Alerte 1 : Le Report expire à la fin de ce mois ! + if (remainingCarry > 0 && cMonth === deadline) { + alertHtml = `
🔥
`; + } + // ⚠️ Alerte 2 : Le solde de base expire à la fin du cycle annuel ! + else if ( + endBal > 0 && + (cMonth === alertMonthEndCycle || cMonth === resetMonth) + ) { + const alertMsg = + window.I18N && window.I18N["fc_alert_burn_days"] + ? window.I18N["fc_alert_burn_days"] + .replace("%s", fmt(endBal)) + .replace("%s", "la fin de cycle") + : `Alerte : ${fmt(endBal)} j perdu(s) à la fin du cycle !`; + alertHtml = `
🔥
`; } cards += `
${conf.type || type}${fmt(endBal)}${totalUsed > 0 ? `-${fmt(totalUsed)}` : ""}${alertHtml}
`; @@ -1689,6 +1895,68 @@ document.addEventListener("DOMContentLoaded", () => { this.renderMonthCalendar(); }), ); + + // Câblage sécurisé du formulaire d'ajustement de solde + const formSnap = document.getElementById("formSnapshot"); + if (formSnap) { + formSnap.addEventListener("submit", async (e) => { + e.preventDefault(); + const btn = + document.getElementById("btnSubmitSnapshot") || + e.target.querySelector('button[type="submit"]'); + const originalText = btn ? btn.innerHTML : ""; + if (btn) { + btn.innerHTML = "⏳..."; + btn.disabled = true; + } + + try { + const payload = { + person_id: document.getElementById("snapPerson").value, + leave_type: document.getElementById("snapType").value, + snapshot_date: document.getElementById("snapDate").value, + remaining_balance: parseFloat( + document.getElementById("snapBalance").value, + ), + carry_over_balance: parseFloat( + document.getElementById("snapCarryOver").value || 0, + ), + }; + + const res = await this.postApi( + "/modules/family-calendar/includes/api/save-leave-snapshot.php", + payload, + ); + if (!res.status || res.status !== "success") + throw new Error(res.message || "Erreur"); + + if (window.showToast) + showToast( + tr("fc_settings_updated") || "Solde corrigé !", + "success", + ); + + const modal = + document.getElementById("modalSnapshot") || + document.getElementById("snapshotModal"); + if (modal) { + modal.style.display = "none"; + modal.classList.remove("open"); + } + document.body.classList.remove("no-scroll"); + formSnap.reset(); + + await this.refreshAllData(); // Recharge les graphiques en direct + } catch (err) { + alert(tr("error_occured") + " " + err.message); + } finally { + if (btn) { + btn.innerHTML = originalText; + btn.disabled = false; + } + } + }); + } } handleMouseDown(e) { diff --git a/modules/family-calendar/includes/api/calendar-settings.php b/modules/family-calendar/includes/api/calendar-settings.php index c20aa26..35dc397 100644 --- a/modules/family-calendar/includes/api/calendar-settings.php +++ b/modules/family-calendar/includes/api/calendar-settings.php @@ -15,7 +15,7 @@ try { $stmtPeople = $pdo->query("SELECT id, name, role, care_modes, color FROM pf_people WHERE is_active = 1 ORDER BY role ASC, name ASC"); $people = $stmtPeople->fetchAll(PDO::FETCH_ASSOC); - $stmtLeaves = $pdo->query("SELECT person_id, leave_type, anniversary_date, method, allowance FROM pf_person_leave_meta"); + $stmtLeaves = $pdo->query("SELECT person_id, leave_type, anniversary_date, method, allowance, carry_over_max_days, carry_over_deadline_month FROM pf_person_leave_meta"); $leavesRaw = $stmtLeaves->fetchAll(PDO::FETCH_ASSOC); $leavesMap = []; @@ -24,7 +24,9 @@ try { 'type' => $leave['leave_type'], 'method' => $leave['method'] ?? 'FIXED', 'allowance' => $leave['allowance'] ?? 0, - 'date' => $leave['anniversary_date'] + 'date' => $leave['anniversary_date'], + 'carry_over_max_days' => $leave['carry_over_max_days'] ?? 0, + 'carry_over_deadline_month' => $leave['carry_over_deadline_month'] ?? 12 ]; } @@ -37,8 +39,8 @@ try { 'calendar_working_hours' => $calendarSettingsRaw['calendar_working_hours'] ?? '08:00-19:00' ]; - // Catalogue des congés - $stmtLeaveTypes = $pdo->query("SELECT code, label, default_allowance, reset_month, allow_carry_over FROM pf_leave_types ORDER BY label ASC"); + // Catalogue des congés (Nettoyé des règles de report qui sont maintenant sur la personne) + $stmtLeaveTypes = $pdo->query("SELECT code, label, default_allowance, reset_month FROM pf_leave_types ORDER BY label ASC"); $leaveTypes = $stmtLeaveTypes->fetchAll(PDO::FETCH_ASSOC); echo json_encode([ @@ -97,20 +99,22 @@ try { } if ($action === 'save_leave_type') { - if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée"); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception(tr('error_occured')); $mode = $_POST['mode'] ?? 'add'; $code = strtoupper(trim($_POST['code'] ?? '')); $label = trim($_POST['label'] ?? ''); + $carryMax = (float)($_POST['carry_over_max_days'] ?? 0); + $carryDeadline = (int)($_POST['carry_over_deadline_month'] ?? 12); - if (empty($code) || empty($label)) throw new Exception("Le code et le label sont obligatoires."); + if (empty($code) || empty($label)) throw new Exception(tr('fc_err_code_label')); if ($mode === 'add') { - $stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label) VALUES (?, ?)"); - $stmt->execute([$code, $label]); + $stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label, carry_over_max_days, carry_over_deadline_month) VALUES (?, ?, ?, ?)"); + $stmt->execute([$code, $label, $carryMax, $carryDeadline]); } else { - $stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ? WHERE code = ?"); - $stmt->execute([$label, $code]); + $stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ?, carry_over_max_days = ?, carry_over_deadline_month = ? WHERE code = ?"); + $stmt->execute([$label, $carryMax, $carryDeadline, $code]); } echo json_encode(['success' => true]); exit; @@ -141,7 +145,7 @@ try { // ─── 5. GESTION DES CONGÉS INDIVIDUELS (MEMBRES) ─── if ($action === 'get_person_leaves') { $personId = (int)($_GET['person_id'] ?? 0); - $stmt = $pdo->prepare("SELECT id, leave_type, allowance, method, anniversary_date FROM pf_person_leave_meta WHERE person_id = ? ORDER BY leave_type ASC"); + $stmt = $pdo->prepare("SELECT id, leave_type, allowance, method, anniversary_date, carry_over_max_days, carry_over_deadline_month FROM pf_person_leave_meta WHERE person_id = ? ORDER BY leave_type ASC"); $stmt->execute([$personId]); echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]); exit; @@ -150,25 +154,35 @@ try { if ($action === 'add_person_leave') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée"); + $id = (int)($_POST['meta_id'] ?? 0); // Nouvel ID pour différencier l'Update de l'Insert $personId = (int)($_POST['person_id'] ?? 0); $type = strtoupper(trim($_POST['leave_type'] ?? '')); $allowance = (float)($_POST['allowance'] ?? 0); $resetMonth = (int)($_POST['reset_month'] ?? 1); + $carryMax = (float)($_POST['carry_over_max_days'] ?? 0); + $carryDeadline = (int)($_POST['carry_over_deadline_month'] ?? 12); $method = in_array($_POST['method'] ?? '', ['FIXED', 'ACCUMULATED']) ? $_POST['method'] : 'FIXED'; if ($personId <= 0 || empty($type)) throw new Exception("Données invalides."); - // Vérification des doublons - $check = $pdo->prepare("SELECT id FROM pf_person_leave_meta WHERE person_id = ? AND leave_type = ?"); - $check->execute([$personId, $type]); - if ($check->rowCount() > 0) throw new Exception("Ce congé est déjà attribué à cette personne."); - // On construit la date anniversaire avec le mois choisi par l'utilisateur $anniversaryDate = "2000-" . str_pad((string)$resetMonth, 2, "0", STR_PAD_LEFT) . "-01"; - $stmt = $pdo->prepare("INSERT INTO pf_person_leave_meta (person_id, leave_type, allowance, method, anniversary_date) VALUES (?, ?, ?, ?, ?)"); - $stmt->execute([$personId, $type, $allowance, $method, $anniversaryDate]); + if ($id > 0) { + // ✏️ UPDATE (Modification) + $stmt = $pdo->prepare("UPDATE pf_person_leave_meta SET allowance = ?, method = ?, anniversary_date = ?, carry_over_max_days = ?, carry_over_deadline_month = ? WHERE id = ? AND person_id = ?"); + $stmt->execute([$allowance, $method, $anniversaryDate, $carryMax, $carryDeadline, $id, $personId]); + } else { + // ➕ INSERT (Ajout) + // Vérification des doublons + $check = $pdo->prepare("SELECT id FROM pf_person_leave_meta WHERE person_id = ? AND leave_type = ?"); + $check->execute([$personId, $type]); + if ($check->rowCount() > 0) throw new Exception("Ce congé est déjà attribué à cette personne."); + + $stmt = $pdo->prepare("INSERT INTO pf_person_leave_meta (person_id, leave_type, allowance, method, anniversary_date, carry_over_max_days, carry_over_deadline_month) VALUES (?, ?, ?, ?, ?, ?, ?)"); + $stmt->execute([$personId, $type, $allowance, $method, $anniversaryDate, $carryMax, $carryDeadline]); + } echo json_encode(['success' => true]); exit; diff --git a/modules/family-calendar/includes/api/get-leave-snapshots.php b/modules/family-calendar/includes/api/get-leave-snapshots.php index 3a93338..3ca81b7 100644 --- a/modules/family-calendar/includes/api/get-leave-snapshots.php +++ b/modules/family-calendar/includes/api/get-leave-snapshots.php @@ -4,7 +4,7 @@ require __DIR__ . '/../../../../includes/db.php'; try { $stmt = $pdo->query(" - SELECT person_id, leave_type, snapshot_date, remaining_balance + SELECT person_id, leave_type, snapshot_date, remaining_balance, carry_over_balance FROM pf_leave_snapshots ORDER BY snapshot_date ASC "); diff --git a/modules/family-calendar/includes/api/save-leave-snapshot.php b/modules/family-calendar/includes/api/save-leave-snapshot.php index 0f9c9fb..53dd3d7 100644 --- a/modules/family-calendar/includes/api/save-leave-snapshot.php +++ b/modules/family-calendar/includes/api/save-leave-snapshot.php @@ -15,13 +15,16 @@ try { $stmtDel = $pdo->prepare("DELETE FROM pf_leave_snapshots WHERE person_id = ? AND leave_type = ? AND snapshot_date = ?"); $stmtDel->execute([$input['person_id'], $input['leave_type'], $input['snapshot_date']]); + $carry_over_balance = isset($input['carry_over_balance']) ? (float)$input['carry_over_balance'] : 0.00; + // Étape 2 : On insère le nouveau solde - $stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance) VALUES (?, ?, ?, ?)"); + $stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance, carry_over_balance) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([ $input['person_id'], $input['leave_type'], $input['snapshot_date'], - $input['remaining_balance'] + $input['remaining_balance'], + $carry_over_balance ]); echo json_encode(['status' => 'success']);