diff --git a/header.php b/header.php index 61a21c4..829bd87 100644 --- a/header.php +++ b/header.php @@ -23,6 +23,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr'; <?= htmlspecialchars($pageTitle) ?> + @@ -122,19 +123,13 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
\ No newline at end of file + + function showToast(message, type = 'success') { + const toast = document.createElement('div'); + toast.className = `pf-toast pf-toast--${type}`; + toast.innerHTML = message; + document.body.appendChild(toast); + + setTimeout(() => toast.classList.add('is-visible'), 100); + setTimeout(() => { + toast.classList.remove('is-visible'); + setTimeout(() => toast.remove(), 300); + }, 3000); + } + + async function pachaConfirm(title, message) { + return new Promise((resolve) => { + const modal = document.createElement('div'); + modal.className = 'pf-modal open'; + modal.innerHTML = ` +
+

${title}

+

${message}

+ +
+ `; + document.body.appendChild(modal); + + document.getElementById('confirm-cancel').onclick = () => { modal.remove(); resolve(false); }; + document.getElementById('confirm-ok').onclick = () => { modal.remove(); resolve(true); }; + }); + } + \ No newline at end of file diff --git a/includes/lang/ca.php b/includes/lang/ca.php index af81c32..39028e3 100644 --- a/includes/lang/ca.php +++ b/includes/lang/ca.php @@ -1036,4 +1036,9 @@ return [ 'food_err_no_views_desc' => 'Heu desactivat totes les funcions d\'aquest mòdul. Aneu a la configuració per reactivar-les.', 'meal_paste_success' => 'Graella enganxada amb èxit!', 'meal_err_filename' => 'Format del nom del fitxer no reconegut. Exemple esperat: "Menus-du-28-septembre-au-4-octobre-2026.pdf".', + + 'offline_title' => 'Mode fora de línia', + 'offline_heading' => 'Estàs fora de línia', + 'offline_message' => 'L\'aplicació HouseHub no està disponible sense connexió a internet ara mateix.', + 'offline_retry_btn' => 'Tornar-ho a intentar', ]; \ No newline at end of file diff --git a/includes/lang/en.php b/includes/lang/en.php index 1edb341..bd10c1e 100644 --- a/includes/lang/en.php +++ b/includes/lang/en.php @@ -1040,4 +1040,9 @@ return [ 'meal_paste_success' => 'Grid pasted successfully!', 'meal_err_filename' => 'Unrecognized file name format. Expected example: "Menus-du-28-septembre-au-4-octobre-2026.pdf".', + 'offline_title' => 'Offline Mode', + 'offline_heading' => 'You are offline', + 'offline_message' => 'The HouseHub application cannot be reached without an internet connection at the moment.', + 'offline_retry_btn' => 'Retry connection', + ]; \ No newline at end of file diff --git a/includes/lang/fr.php b/includes/lang/fr.php index d08fb25..dfcf1b2 100644 --- a/includes/lang/fr.php +++ b/includes/lang/fr.php @@ -1036,4 +1036,9 @@ return [ 'meal_err_filename' => 'Format de nom de fichier non reconnu. Exemple attendu : "Menus-du-28-septembre-au-4-octobre-2026.pdf".', 'meal_paste_success' => 'Grille collée avec succès !', + 'offline_title' => 'Mode hors ligne', + 'offline_heading' => 'Vous êtes hors ligne', + 'offline_message' => 'L\'application HouseHub n\'est pas joignable sans connexion internet pour le moment.', + 'offline_retry_btn' => 'Réessayer de se connecter', + ]; \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b46f0f0 --- /dev/null +++ b/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "HouseHub OS", + "short_name": "HouseHub", + "description": "Système de gestion familiale", + "start_url": "/index.php", + "display": "standalone", + "background_color": "#f8fafc", + "theme_color": "#3b82f6", + "icons": [ + { + "src": "/favicon.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/modules/budget/views/suivi.php b/modules/budget/views/suivi.php index 8818acb..7af8274 100644 --- a/modules/budget/views/suivi.php +++ b/modules/budget/views/suivi.php @@ -238,11 +238,28 @@ $fixedChargesList = []; $incomeList = []; $pending_charges = []; // MAPPING DYNAMIQUE DES BUDGETS PRÉVISIONNELS (NOUVELLE LOGIQUE) // ============================================================================ // On exclut "SAVINGS" pour s'aligner à 100% avec le recap.php -$stmt = $pdo->query("SELECT id, name, amount, type, category, is_estimate, payment_day, mapping_keywords FROM pf_budget_items WHERE category != 'SAVINGS' ORDER BY name ASC"); +$stmt = $pdo->query("SELECT * FROM pf_budget_items WHERE category != 'SAVINGS' ORDER BY name ASC"); $estimatesList = []; // On prépare la liste pour retenir nos estimations multi-catégories while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) { $absAmount = abs((float)$item['amount']); + + // --- 🚀 INTERCEPTION DYNAMIQUE --- + $calcDetails = ""; + if (isset($item['is_dynamic']) && $item['is_dynamic'] == 1 && !empty($item['dynamic_code'])) { + require_once __DIR__ . '/../includes/DynamicBudgetCalculator.php'; + // Facturation à terme échu : on recule d'un mois par rapport au mois actif du suivi + $prevDate = date('Y-m-d', strtotime('-1 month', strtotime($viewMonthDate))); + $calcYear = date('Y', strtotime($prevDate)); + $calcMonth = date('m', strtotime($prevDate)); + + $calc = new DynamicBudgetCalculator($pdo, $calcYear, $calcMonth); + $estimateData = $calc->getEstimate($item['dynamic_code']); + $absAmount = $estimateData['amount']; + $calcDetails = $estimateData['details']; + } + // -------------------------------- + $amt = ($item['type'] === 'Annuel') ? $absAmount / 12 : $absAmount; $name = trim($item['name']); $catCode = $item['category']; @@ -280,39 +297,23 @@ while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) { } if (!$isPaid) { $reste_a_venir_calc += $absAmount; - // Ajouté dans le tableau avec un tooltip vide - $pending_charges[] = ['name' => $name, 'amount' => $absAmount, 'tooltip' => 'Charge fixe en attente']; + // On inclut les détails de l'interception dans le tooltip natif du suivi + $tooltip = !empty($calcDetails) ? $calcDetails : 'Charge fixe en attente'; + $pending_charges[] = ['name' => $name, 'amount' => $absAmount, 'tooltip' => $tooltip]; } } // 2. C'est une estimation (Variable) if ((int)$item['is_estimate'] === 1) { - $calcDetails = ""; - - // --- 🚀 INTERCEPTION DYNAMIQUE --- - if (isset($item['is_dynamic']) && $item['is_dynamic'] == 1 && !empty($item['dynamic_code'])) { - require_once __DIR__ . '/../includes/DynamicBudgetCalculator.php'; - // Facturation à terme échu : on recule d'un mois - $prevDate = date('Y-m-d', strtotime('-1 month', strtotime("$viewY-$viewM-01"))); - $calcYear = date('Y', strtotime($prevDate)); - $calcMonth = date('m', strtotime($prevDate)); - - $calc = new DynamicBudgetCalculator($pdo, $calcYear, $calcMonth); - $estimateData = $calc->getEstimate($item['dynamic_code']); - $amt = $estimateData['amount']; - $calcDetails = $estimateData['details']; - } - // -------------------------------- - $estimatesList[] = [ 'name' => $name, 'amount' => $amt, 'categories' => !empty($catCode) ? array_map('trim', explode(',', $catCode)) : [], - 'details' => $calcDetails + 'calc_details' => $calcDetails // Transmission pour le second bloc ]; } + // 3. Attribution du budget aux jauges visuelles de la page - // Pour ne pas tout casser, on donne tout le plafond visuel à la 1ère catégorie de la liste if (!empty($catCode)) { $catCodesArray = array_map('trim', explode(',', $catCode)); $firstCat = $catCodesArray[0]; @@ -369,9 +370,11 @@ foreach ($estimatesList as $est) { $spentForEstimate = 0; $detailsHover = []; + // On additionne les dépenses de toutes les catégories liées à cette estimation foreach ($est['categories'] as $cCode) { if (!empty($cCode) && isset($totals[$cCode])) { $spentForEstimate += $totals[$cCode]; + // On prépare le détail pour la petite bulle d'info (hover) if ($totals[$cCode] > 0 && isset($categoriesConfig[$cCode])) { $detailsHover[] = strip_tags($categoriesConfig[$cCode]['label']) . " : " . number_format($totals[$cCode], 0) . "€"; } @@ -381,13 +384,10 @@ foreach ($estimatesList as $est) { $rem = max(0, $est['amount'] - $spentForEstimate); if ($rem > 0) { $reste_a_venir_calc += $rem; - $tooltip = !empty($detailsHover) ? implode(' | ', $detailsHover) : 'Aucune dépense pour le moment'; + // On fusionne les détails du calcul dynamique (s'il y en a) avec les détails des dépenses réelles + $baseTooltip = !empty($detailsHover) ? implode(' | ', $detailsHover) : 'Aucune dépense pour le moment'; + $tooltip = !empty($est['calc_details']) ? $est['calc_details'] . " | " . $baseTooltip : $baseTooltip; - // --- INJECTION DU DÉTAIL DYNAMIQUE --- - if (!empty($est['details'])) { - $tooltip .= "\n\n📊 Détail du calcul :\n" . $est['details']; - } - $pending_charges[] = [ 'name' => 'Reste ' . $est['name'], 'amount' => $rem, diff --git a/offline.php b/offline.php new file mode 100644 index 0000000..4372300 --- /dev/null +++ b/offline.php @@ -0,0 +1,21 @@ + + +
+
📡
+

+

+ +

+ +
+ + \ No newline at end of file diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..417d00d --- /dev/null +++ b/sw.js @@ -0,0 +1,153 @@ +const CACHE_NAME = "househub-static-v3"; +const API_CACHE_NAME = "househub-api-v1"; +const ASSETS_TO_CACHE = [ + "/offline.php", + "/global.css", + "/dark-mode.css", + "/favicon.png", +]; + +// --- Gestion de la file d'attente (IndexedDB) pour les actions hors ligne --- +function openDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open("househub-offline-sync", 1); + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains("sync-queue")) { + db.createObjectStore("sync-queue", { autoIncrement: true }); + } + }; + request.onsuccess = (event) => resolve(event.target.result); + request.onerror = (event) => reject(event.target.error); + }); +} + +async function dequeueSyncActions() { + try { + const db = await openDB(); + const transaction = db.transaction("sync-queue", "readwrite"); + const store = transaction.objectStore("sync-queue"); + const request = store.getAll(); + + request.onsuccess = async () => { + const actions = request.result; + for (const action of actions) { + try { + await fetch(action.url, { + method: action.method, + headers: action.headers, + body: action.body, + }); + + // Suppression si succès + const delTx = db.transaction("sync-queue", "readwrite"); + delTx.objectStore("sync-queue").delete(action.id); + } catch (err) { + console.error("Erreur de synchronisation en arrière-plan :", err); + break; + } + } + }; + } catch (e) { + console.error("Erreur ouverture DB dans le Service Worker", e); + } +} + +// Background Sync exclusif à Chromium +self.addEventListener("sync", (event) => { + if (event.tag === "househub-sync") { + event.waitUntil(dequeueSyncActions()); + } +}); + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + // Utilise catch pour éviter qu'un fichier manquant (ex: favicon) fasse crasher l'installation + return cache + .addAll(ASSETS_TO_CACHE) + .catch((err) => console.error("Erreur de cache initial", err)); + }), + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME && cacheName !== API_CACHE_NAME) { + return caches.delete(cacheName); + } + }), + ); + }), + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const requestUrl = new URL(event.request.url); + + if ( + requestUrl.pathname.includes("/api/") || + requestUrl.pathname.includes("api.php") + ) { + if (event.request.method === "GET") { + event.respondWith( + fetch(event.request) + .then((response) => { + const responseClone = response.clone(); + caches + .open(API_CACHE_NAME) + .then((cache) => cache.put(event.request, responseClone)); + return response; + }) + .catch(() => caches.match(event.request)), + ); + } + return; + } + + if (event.request.mode === "navigate") { + event.respondWith( + fetch(event.request) + .then((response) => { + const responseClone = response.clone(); + caches + .open(CACHE_NAME) + .then((cache) => cache.put(event.request, responseClone)); + return response; + }) + .catch(() => { + return caches.match(event.request).then((cachedResponse) => { + return cachedResponse || caches.match("/offline.php"); + }); + }), + ); + return; + } + + event.respondWith( + caches.match(event.request).then((response) => { + if (response) return response; + return fetch(event.request) + .then((networkResponse) => { + if ( + networkResponse && + networkResponse.status === 200 && + (networkResponse.type === "basic" || + networkResponse.type === "cors") + ) { + const responseToCache = networkResponse.clone(); + caches + .open(CACHE_NAME) + .then((cache) => cache.put(event.request, responseToCache)); + } + return networkResponse; + }) + .catch(() => null); + }), + ); +});