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';
+
+
+ = tr('fc_carry_max') ?>
+
+
+
+ = tr('fc_carry_deadline') ?>
+
+ = tr('month_01') ?>
+ = tr('month_02') ?>
+ = tr('month_03') ?>
+ = tr('month_04') ?>
+ = tr('month_05') ?>
+ = tr('month_06') ?>
+ = tr('fc_deadline_none') ?>
+
+
+
+
= tr('btn_cancel') ?>
= tr('btn_save') ?>
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) ";
+ foreach ($families as $fam) {
+ $db = $fam['db_name'];
+ echo "Migration sur la base : $db... ";
+
+ $pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $pass, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
+ ]);
+ // 1. pf_person_leave_meta : Règles de report individuelles
+ $pdo->exec("ALTER TABLE pf_person_leave_meta ADD COLUMN IF NOT EXISTS carry_over_max_days DECIMAL(5,2) DEFAULT 0.00 AFTER allowance");
+ $pdo->exec("ALTER TABLE pf_person_leave_meta ADD COLUMN IF NOT EXISTS carry_over_deadline_month INT(11) DEFAULT 12 AFTER carry_over_max_days");
+
+ // 2. pf_leave_snapshots : Ajout du solde de report périssable
+ $pdo->exec("ALTER TABLE pf_leave_snapshots ADD COLUMN IF NOT EXISTS carry_over_balance DECIMAL(6,2) DEFAULT 0.00 AFTER remaining_balance");
+
+ // 3. pf_leave_types : Nettoyage de l'ancien booléen global s'il existe
try {
- $pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
- ]);
-
- // 1. On vérifie si la clé primaire existe déjà pour éviter de faire planter le script
- $hasPrimaryKey = $pdo->query("SHOW KEYS FROM pf_holidays WHERE Key_name = 'PRIMARY'")->fetch();
-
- if (!$hasPrimaryKey) {
- $pdo->exec("ALTER TABLE pf_holidays ADD PRIMARY KEY (id)");
- echo "✅ Clé primaire restaurée sur la colonne `id`. ";
- }
-
- // 2. On restaure la propriété AUTO_INCREMENT
- $pdo->exec("ALTER TABLE pf_holidays MODIFY id INT(11) NOT NULL AUTO_INCREMENT");
- echo "✅ Propriété AUTO_INCREMENT restaurée. ";
-
- } catch (\PDOException $e) {
- // On attrape l'erreur si la modif a déjà été faite
- echo "ℹ️ Action ignorée ou table déjà à jour : " . $e->getMessage() . " ";
+ $pdo->exec("ALTER TABLE pf_leave_types DROP COLUMN allow_carry_over");
+ } catch (Exception $e) {
+ // La colonne n'existait pas ou est déjà supprimée
}
- echo " ";
- }
- 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é
-
-
-
Type de congé
-
+
+
+
+
+
+
+ Type de congé
+
-- Sélectionner --
${globalLeaveCatalog.map((lt) => `${lt.label} `).join("")}
-
- Quota
-
-
-
-
Renouvellement
-
- ${tr("month_jan") || "Janvier"}
- ${tr("month_feb") || "Février"}
- ${tr("month_mar") || "Mars"}
- ${tr("month_apr") || "Avril"}
- ${tr("month_may") || "Mai"}
- ${tr("month_jun") || "Juin"}
- ${tr("month_jul") || "Juillet"}
- ${tr("month_aug") || "Août"}
- ${tr("month_sep") || "Septembre"}
- ${tr("month_oct") || "Octobre"}
- ${tr("month_nov") || "Novembre"}
- ${tr("month_dec") || "Décembre"}
+
+
+ Acquisition
+
+ Fixe (Annuelle)
+ Graduelle (Mensuelle)
-
-
Acquisition
-
- Fixe
- Graduel
+
+
+ Quota
+
+
+
+
+ Renouvellement
+
+ ${tr("month_01") || "Janvier"}
+ ${tr("month_02") || "Février"}
+ ${tr("month_03") || "Mars"}
+ ${tr("month_04") || "Avril"}
+ ${tr("month_05") || "Mai"}
+ ${tr("month_06") || "Juin"}
+ ${tr("month_07") || "Juillet"}
+ ${tr("month_08") || "Août"}
+ ${tr("month_09") || "Septembre"}
+ ${tr("month_10") || "Octobre"}
+ ${tr("month_11") || "Novembre"}
+ ${tr("month_12") || "Décembre"}
- Ajouter
+
+
+
+
+
⚙️ Règles de report (Optionnel)
+
+
+ ${tr("fc_carry_max") || "Max Reportable"}
+
+
+
+ ${tr("fc_carry_deadline") || "Mois Expiration"}
+
+ ${tr("month_01") || "Janvier"}
+ ${tr("month_02") || "Février"}
+ ${tr("month_03") || "Mars"}
+ ${tr("month_04") || "Avril"}
+ ${tr("fc_deadline_none") || "Aucune"}
+
+
+
+
+
+
+ ${tr("btn_cancel") || "Annuler"}
+ Ajouter / Enregistrer
`;
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']);