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';
\ 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 @@
+
+
+
+
📡
+
= tr('offline_heading') ?>
+
+ = tr('offline_message') ?>
+
+
+
+
+
\ 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);
+ }),
+ );
+});