@@ -348,7 +348,7 @@ function editCustomSavingsMonth(monthDate, owner, rowData) {
const dateObj = new Date(monthDate);
const monthName = dateObj.toLocaleDateString(window.appLang, { month: 'long', year: 'numeric' });
- document.getElementById('savingsModalTitle').innerText = tr("bud_sav_modal_title_edit") + " " + monthName + " (" + owner + ")";
+ document.getElementById('savingsModalTitle').innerText = (window.I18N['bud_sav_modal_title_edit'] || 'Editer') + " " + monthName + " (" + owner + ")";
document.getElementById('sav_total').value = rowData['TOTAL_BANQUE'] || '';
@@ -370,7 +370,7 @@ function openCustomSavingsModal(owner) {
document.getElementById('sav_month').value = '';
document.getElementById('sav_total').value = '';
- document.getElementById('savingsModalTitle').innerText = tr("bud_sav_modal_title_add") + " (" + owner + ")";
+ document.getElementById('savingsModalTitle').innerText = (window.I18N['bud_sav_modal_title_add'] || 'Ajouter') + " (" + owner + ")";
const container = document.getElementById('linesContainer');
container.innerHTML = '';
@@ -400,17 +400,20 @@ if (savingsForm) {
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerText;
- submitBtn.innerText = tr('bud_sav_saving');
+ submitBtn.innerText = window.I18N['bud_sav_saving'] || 'Sauvegarde...';
submitBtn.disabled = true;
const formData = new FormData(this);
- fetch(this.action, { method: 'POST', body: formData })
+ const actionUrl = this.getAttribute('action');
+ const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl;
+
+ fetch(finalUrl, { method: 'POST', body: formData })
.then(response => response.text())
.then(text => { window.location.reload(); })
.catch(error => {
console.error("Erreur:", error);
- alert(tr("bud_err_tech"));
+ alert(window.I18N['bud_err_tech'] || 'Erreur Technique');
submitBtn.innerText = originalText;
submitBtn.disabled = false;
});
@@ -418,16 +421,18 @@ if (savingsForm) {
}
function deleteEntireMonth(monthDate, owner) {
- const msg = tr('bud_sav_confirm_delete_month').replace('%m', monthDate).replace('%o', owner);
+ const rawMsg = window.I18N['bud_sav_confirm_delete_month'] || "Supprimer %m pour %o ?";
+ const msg = rawMsg.replace('%m', monthDate).replace('%o', owner);
if (!confirm(msg)) return;
const formData = new FormData();
formData.append("action", "delete_month_global");
formData.append("month_date", monthDate);
formData.append("owner", owner);
- fetch("/modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
+
+ fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
.then(() => window.location.reload())
- .catch(err => alert(tr('bud_err_delete')));
+ .catch(err => alert(window.I18N['bud_err_delete'] || 'Erreur lors de la suppression'));
}
function duplicateLastMonth(lastMonthDate, owner) {
@@ -450,10 +455,8 @@ function duplicateLastMonth(lastMonthDate, owner) {
defaultTotal = cycleConfigs[nextMonthStr].start_balance;
}
- const message = tr('bud_sav_prompt_duplicate')
- .replace('%s', sourceName)
- .replace('%t1', targetName)
- .replace('%t2', targetName);
+ const rawMsg = window.I18N['bud_sav_prompt_duplicate'] || "Dupliquer %s vers %t1 ?";
+ const message = rawMsg.replace('%s', sourceName).replace('%t1', targetName).replace('%t2', targetName);
let newTotal = prompt(message, defaultTotal);
@@ -465,20 +468,20 @@ function duplicateLastMonth(lastMonthDate, owner) {
formData.append("new_total", newTotal);
formData.append("owner", owner);
- fetch("/modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
+ fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
.then(async r => {
const text = await r.text();
try {
const d = JSON.parse(text);
if (d.success) window.location.reload();
- else alert(tr('bud_err_server') + (d.error || "Inconnue"));
+ else alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (d.error || "Inconnue"));
} catch(e) {
window.location.reload();
}
})
.catch(err => {
console.error(err);
- alert(tr("bud_err_network_dup"));
+ alert(window.I18N['bud_err_network_dup'] || 'Erreur réseau.');
});
}
}
diff --git a/modules/budget/views/suivi.php b/modules/budget/views/suivi.php
index 3db75c1..e1fe792 100644
--- a/modules/budget/views/suivi.php
+++ b/modules/budget/views/suivi.php
@@ -57,36 +57,7 @@ if (isset($_POST['action']) && $_POST['action'] === 'reopen_month') {
exit;
}
-if (isset($_POST['action']) && $_POST['action'] === 'save_expense_manual') {
- $id = !empty($_POST['expense_id']) ? (int)$_POST['expense_id'] : null;
- $cat = $_POST['category'];
- $amount = floatval($_POST['amount']);
- $date = $_POST['date'];
- $gestionMonth = $_POST['gestion_month'];
- $label = trim($_POST['label']);
- $budgetItemId = null;
- $holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
- if ($cat === 'School' && !empty($_POST['label_select'])) $label = trim($_POST['label_select']);
- elseif (($cat === 'Frais' || $cat === 'Income') && !empty($_POST['budget_item_id'])) $budgetItemId = (int)$_POST['budget_item_id'];
-
- if ($label && $amount > 0) {
- $is_credit = isset($_POST['is_credit']) ? (int)$_POST['is_credit'] : 0;
- $finalAmount = $is_credit ? abs($amount) : -abs($amount);
-
- if ($id) {
- $pdo->prepare("UPDATE pf_expenses SET date_exp=?, gestion_month=?, category=?, label=?, amount=?, budget_item_id=?, holiday_id=? WHERE id=?")
- ->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $budgetItemId, $holidayId, $id]);
- } else {
- $uniqueRef = "MANUAL_" . uniqid();
- $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
- ->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $uniqueRef, $budgetItemId, $holidayId]);
- }
-
- echo "";
- exit;
- }
-}
if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
$count = 0;
@@ -766,4 +737,65 @@ function checkValidation() {
}
if(document.getElementById('formMapping')) { document.querySelectorAll('.line-select').forEach(s => handleLineCatChange(s)); checkValidation(); }
+
+// Ferme la modale si on clique sur le background assombri (.pf-modal)
+window.addEventListener('click', (e) => {
+ if (e.target.classList.contains('pf-modal')) {
+ e.target.style.display = 'none';
+ document.body.classList.remove('no-scroll');
+ }
+});
+
+document.addEventListener('DOMContentLoaded', () => {
+ const formExpense = document.querySelector('#manualExpenseModal form');
+
+ if (formExpense) {
+ formExpense.addEventListener('submit', async (e) => {
+ e.preventDefault(); // 🛑 On bloque le rechargement brutal de la page
+
+ const submitBtn = formExpense.querySelector('button[type="submit"]');
+ const originalBtnText = submitBtn.innerText;
+
+ try {
+ // 🛡️ Sécurité UI : on désactive le bouton pour éviter les doubles clics (et les doublons en BDD)
+ submitBtn.disabled = true;
+ submitBtn.innerText = '⏳ ...';
+
+ const formData = new FormData(formExpense);
+ // On s'assure que l'action est bien définie pour notre API
+ formData.set('action', 'save_expense_manual');
+
+ const response = await fetch('modules/budget/includes/api/manage-item.php', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (!response.ok) throw new Error(`Erreur HTTP: ${response.status}`);
+
+ const result = await response.json();
+
+ if (result.success) {
+ // ✅ Succès : Fermeture propre de la modale
+ closeSuiviModal('manualExpenseModal');
+ formExpense.reset();
+
+ // Pour l'instant, on fait un rechargement propre pour voir les calculs à jour.
+ // Dans une V2 ultra-opti, on mettra à jour le DOM ligne par ligne ici !
+ window.location.reload();
+ } else {
+ // ❌ Erreur renvoyée par le PHP
+ const errorMsg = result.error || 'Erreur inconnue';
+ alert(window.I18N ? window.I18N.tr('error_occured') + ' : ' + errorMsg : errorMsg);
+ }
+ } catch (error) {
+ console.error("Erreur lors de l'enregistrement :", error);
+ alert("Une erreur critique est survenue côté réseau ou serveur.");
+ } finally {
+ // 🔄 On restaure le bouton dans tous les cas (succès ou échec)
+ submitBtn.disabled = false;
+ submitBtn.innerText = originalBtnText;
+ }
+ });
+ }
+ });
\ No newline at end of file
diff --git a/test-engine.js b/test-engine.js
new file mode 100644
index 0000000..4978237
--- /dev/null
+++ b/test-engine.js
@@ -0,0 +1,339 @@
+/**
+ * PachaFamily - Test Engine 🦙
+ * Moteur de tests automatisés (E2E) - Version Anti-Flakiness
+ */
+
+const PachaTestEngine = {
+ arena: document.getElementById("test-arena"),
+ reportBox: document.getElementById("test-report"),
+ doc: null,
+
+ // ==========================================
+ // 🛠️ HELPERS
+ // ==========================================
+
+ wait: (ms) => new Promise((r) => setTimeout(r, ms)),
+
+ log: function (msg, icon = "ℹ️") {
+ this.reportBox.value += `[${new Date().toLocaleTimeString()}] ${icon} ${msg}\n`;
+ this.reportBox.scrollTop = this.reportBox.scrollHeight;
+ },
+
+ assert: function (cond, msgPass, msgFail) {
+ this.log(cond ? msgPass : msgFail || msgPass, cond ? "✅" : "❌");
+ },
+
+ // 🛡️ NOUVEAU : Attend le rechargement de la page après une action
+ actionAndWaitForReload: async function (actionFn, timeout = 5000) {
+ return new Promise(async (resolve) => {
+ let reloaded = false;
+ // On écoute le prochain rechargement de l'iframe
+ this.arena.onload = () => {
+ reloaded = true;
+ this.doc = this.arena.contentWindow.document;
+ this.arena.contentWindow.confirm = () => true;
+ this.arena.contentWindow.alert = () => true;
+ resolve();
+ };
+
+ await actionFn(); // On lance le clic ou la soumission
+
+ // Sécurité anti-blocage (si le fetch échoue et ne recharge pas la page)
+ setTimeout(() => {
+ if (!reloaded) {
+ this.log(
+ "⚠️ Le rechargement de la page n'a pas eu lieu dans le temps imparti.",
+ "⏱️",
+ );
+ resolve();
+ }
+ }, timeout);
+ });
+ },
+
+ load: async function (url) {
+ this.log(`Chargement de la page : ${url}`, "🔄");
+ return new Promise((resolve) => {
+ this.arena.onload = () => {
+ this.doc = this.arena.contentWindow.document;
+ this.arena.contentWindow.confirm = () => true;
+ this.arena.contentWindow.alert = () => true;
+ resolve();
+ };
+ this.arena.src = url;
+ });
+ },
+
+ get: function (sel) {
+ return this.doc.querySelector(sel);
+ },
+
+ // 🖱️ Clic unique et infaillible
+ click: async function (sel, delay = 500) {
+ const el = typeof sel === "string" ? this.get(sel) : sel;
+ if (el) {
+ // 1. Coupe les popups bloquantes
+ this.arena.contentWindow.confirm = () => true;
+ this.arena.contentWindow.alert = () => true;
+
+ // 2. Un seul et unique clic !
+ el.click();
+
+ await this.wait(delay);
+ return true;
+ }
+ return false;
+ },
+
+ fill: function (sel, val) {
+ const el = this.get(sel);
+ if (el) el.value = val;
+ },
+
+ select: async function (sel, val, delay = 300) {
+ const el = this.get(sel);
+ if (el) {
+ el.value = val;
+ el.dispatchEvent(new Event("change"));
+ await this.wait(delay);
+ }
+ },
+
+ findInTable: function (text) {
+ return Array.from(this.doc.querySelectorAll("td, div")).find((el) =>
+ el.textContent.includes(text),
+ );
+ },
+
+ // ==========================================
+ // 🧪 SCÉNARIO 1 : SUIVI MENSUEL
+ // ==========================================
+
+ runBudgetTests: async function () {
+ this.reportBox.value = "";
+ this.log("=== 🚀 DÉBUT DU SCÉNARIO : SUIVI BUDGET ===", "INFO");
+
+ await this.load("budget.php?tab=suivi");
+ await this.wait(1000);
+
+ if (!this.get(".btn-add-item")) {
+ this.log("Mois clôturé, test annulé. Déverrouille-le !", "⚠️");
+ return;
+ }
+
+ await this.click("button[onclick=\"toggleDiv('pendingDetailsList')\"]");
+ this.assert(
+ this.get("#pendingDetailsList").style.display !== "none",
+ "Déploiement : Charges à venir",
+ );
+
+ await this.click(".btn-add-item");
+ await this.select("#modalCatSelect", "Autres");
+ this.fill("#modalAmount", "42.42");
+ this.fill("#modalLabelInput", "TEST_AUTO_PACHA");
+
+ this.log("Enregistrement de la dépense...", "WARN");
+ await this.actionAndWaitForReload(async () => {
+ await this.click('#manualExpenseModal button[type="submit"]');
+ });
+
+ let targetTd = Array.from(this.doc.querySelectorAll("td")).find((el) =>
+ el.textContent.includes("TEST_AUTO_PACHA"),
+ );
+ this.assert(
+ targetTd !== undefined,
+ "Dépense créée avec succès (42.42€).",
+ "Échec : Dépense introuvable.",
+ );
+
+ if (targetTd) {
+ this.log("Nettoyage de la base de données...", "WARN");
+ await this.actionAndWaitForReload(async () => {
+ await this.click(
+ targetTd.closest("tr").querySelector('a[href*="delete_expense"]'),
+ );
+ });
+ const checkGone = Array.from(this.doc.querySelectorAll("td")).find((el) =>
+ el.textContent.includes("TEST_AUTO_PACHA"),
+ );
+ this.assert(
+ checkGone === undefined,
+ "Base de données nettoyée avec succès.",
+ );
+ }
+
+ this.log("=== 🏁 FIN DU SCÉNARIO ===", "INFO");
+ },
+
+ // ==========================================
+ // 🧪 SCÉNARIO 2 : BUDGET PRÉVISIONNEL
+ // ==========================================
+
+ testBudgetPrev: async function () {
+ this.reportBox.value = "";
+ this.log("=== 🚀 DÉBUT DU SCÉNARIO : BUDGET PRÉVISIONNEL ===", "INFO");
+
+ await this.load("budget.php?tab=budget_prev");
+ await this.wait(1000);
+
+ this.log("🔍 Étape 1 : Test du mode Somme flottant...", "INFO");
+ await this.click("#fabSumMode");
+ this.assert(
+ this.doc.body.classList.contains("sum-mode-active"),
+ "Le mode somme s'active.",
+ "Le bouton somme ne répond pas.",
+ );
+
+ const firstSalaryInput = this.get('input[data-field="salary"]');
+ if (firstSalaryInput) {
+ await this.click(firstSalaryInput, 500);
+ this.assert(
+ firstSalaryInput.classList.contains("sum-selected"),
+ "La cellule est bien sélectionnée.",
+ );
+ const sumValue = this.get("#sumResultValue").innerText;
+ this.assert(
+ sumValue !== "0,00 €" && sumValue !== "0 €",
+ `Le total interactif affiche : ${sumValue}`,
+ "Le calcul de la somme a échoué.",
+ );
+ }
+
+ await this.click(".pf-sum-close");
+ this.assert(
+ !this.doc.body.classList.contains("sum-mode-active"),
+ "Le mode somme se désactive.",
+ );
+
+ this.log("🔍 Étape 2 : Test de sauvegarde de la note...", "INFO");
+ const noteArea = this.get("#monthNoteArea");
+ if (noteArea) {
+ const oldNote = noteArea.value;
+ this.fill("#monthNoteArea", "🤖 Test Auto");
+ await this.click('button[onclick^="saveGenericNote"]', 1500);
+
+ const indicator = this.get("#note-save-indicator");
+ this.assert(
+ indicator !== null,
+ "Sauvegarde asynchrone exécutée.",
+ "L'indicateur de sauvegarde ne s'est pas affiché.",
+ );
+
+ this.fill("#monthNoteArea", oldNote);
+ await this.click('button[onclick^="saveGenericNote"]', 500);
+ }
+
+ const uniqueCatName = "TEST_CAT_" + Math.floor(Math.random() * 10000);
+ this.log(
+ `🔍 Étape 3 : Création de la catégorie '${uniqueCatName}'...`,
+ "INFO",
+ );
+
+ await this.click('button[onclick*="addCatModal"]');
+ this.assert(
+ this.get("#addCatModal").style.display === "flex",
+ "Ouverture de la modale d'ajout.",
+ );
+
+ this.fill('#addCatModal input[name="name"]', uniqueCatName);
+ await this.select('#addCatModal select[name="target"]', "vers commune");
+
+ this.log("Attente du rechargement après création...", "WARN");
+ // 🔄 UTILISATION DU NOUVEAU HELPER SYNCHRONE
+ await this.actionAndWaitForReload(async () => {
+ await this.click('#addCatModal button[type="submit"]');
+ });
+
+ const newCatCell = Array.from(
+ this.doc.querySelectorAll(".prev-alloc-table td, .prev-alloc-table div"),
+ ).find((el) => el.textContent.includes(uniqueCatName));
+ this.assert(
+ newCatCell !== undefined,
+ `SUCCÈS : '${uniqueCatName}' insérée au tableau !`,
+ "ÉCHEC : Ligne introuvable après rechargement.",
+ );
+
+ if (newCatCell) {
+ this.log("🧹 Étape 4 : Nettoyage de la BDD...", "WARN");
+ const row = newCatCell.closest("tr");
+ const delBtn = row.querySelector(".delete");
+
+ if (delBtn) {
+ // 🔄 UTILISATION DU NOUVEAU HELPER SYNCHRONE
+ await this.actionAndWaitForReload(async () => {
+ await this.click(delBtn);
+ });
+
+ const checkGone = Array.from(
+ this.doc.querySelectorAll(".prev-alloc-table td"),
+ ).find((td) => td.textContent.includes(uniqueCatName));
+ this.assert(
+ checkGone === undefined,
+ "NETTOYAGE PARFAIT : Ligne effacée.",
+ "La ligne est restée dans le tableau.",
+ );
+ } else {
+ this.log("Bouton de suppression (.delete) introuvable.", "FAIL");
+ }
+ }
+
+ this.log("=== 🏁 FIN DU SCÉNARIO ===", "INFO");
+ },
+};
+
+// ============================================================================
+// ROUTEUR DU LABORATOIRE ET BOUTON COPIER
+// ============================================================================
+
+document.getElementById("btn-run-test")?.addEventListener("click", async () => {
+ const selectedTest = document.getElementById("test-selector").value;
+ const btnRun = document.getElementById("btn-run-test");
+
+ btnRun.disabled = true;
+ btnRun.style.opacity = "0.5";
+
+ try {
+ switch (selectedTest) {
+ case "budget_suivi":
+ await PachaTestEngine.runBudgetTests();
+ break;
+ case "budget_prev":
+ await PachaTestEngine.testBudgetPrev();
+ break;
+ default:
+ PachaTestEngine.log(`Scénario non implémenté : ${selectedTest}`, "⚠️");
+ }
+ } catch (error) {
+ PachaTestEngine.log(`Erreur JS critique : ${error.message}`, "❌");
+ console.error(error);
+ }
+
+ btnRun.disabled = false;
+ btnRun.style.opacity = "1";
+});
+
+// 📋 Bouton "Copier"
+document
+ .getElementById("btn-copy-report")
+ ?.addEventListener("click", async () => {
+ const textArea = document.getElementById("test-report");
+ const reportText = textArea.value;
+ const successMsg = window.I18N
+ ? window.I18N.tests_report_copied || "Rapport copié !"
+ : "Rapport copié !";
+
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(reportText);
+ alert(successMsg);
+ } else {
+ textArea.select();
+ document.execCommand("copy");
+ window.getSelection().removeAllRanges();
+ alert(successMsg);
+ }
+ } catch (err) {
+ alert("Erreur lors de la copie du rapport.");
+ console.error(err);
+ }
+ });
diff --git a/tests.php b/tests.php
new file mode 100644
index 0000000..5db96e3
--- /dev/null
+++ b/tests.php
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file