suivi
Deploy HouseHub / deploy (push) Successful in 2s

This commit is contained in:
FeFeClochette committed 2026-10-05 19:00:14 +02:00
1 parent 815266383b
commit 165a0b371c
8 files changed
+366 -76

No files matched your search

+128 -48
View File
@@ -23,6 +23,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/favicon.png">
<link rel="stylesheet" href="/global.css">
@@ -122,19 +123,13 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<main class="pf-main<?= ($mainClass ?? '') ? ' '.htmlspecialchars($mainClass) : '' ?>">
<script>
// CSRF avant I18N : le parseur HTML coupe le script dès la séquence de fin de balise « script » (même dans un commentaire JS).
// CSRF avant I18N
window.CSRF_TOKEN = "<?= htmlspecialchars(function_exists('csrf_token') ? csrf_token() : '', ENT_QUOTES, 'UTF-8') ?>";
/**
* Pont d'Internationalisation et Configuration
*/
window.I18N = <?php echo json_encode($current_translations_array ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE); ?>;
// ✅ Injection sécurisée
window.CONFIG = {
// Liste complète des profils de la famille (Membres, Nounou, Enfants)
PEOPLE: <?= isset($familyPeople) ? json_encode($familyPeople) : '[]' ?>,
// ID du user global connecté (pour savoir "qui" clique)
CURRENT_USER_ID: <?= isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : 'null' ?>,
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>',
ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>'
@@ -144,14 +139,20 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
return window.I18N[key] || key;
}
// Thème clair/sombre — état visuel géré entièrement par CSS via [data-theme="dark"]
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(err => {
console.error('Service Worker registration failed:', err);
});
});
}
function toggleTheme(){
const dark=document.documentElement.getAttribute('data-theme')==='dark';
document.documentElement.setAttribute('data-theme',dark?'light':'dark');
localStorage.setItem('hh-theme',dark?'light':'dark');
}
// Gestion du menu mobile (Off-Canvas Sidebar)
<?php if (isset($_SESSION['user'])): ?>
document.addEventListener('DOMContentLoaded', () => {
const burgerBtn = document.querySelector('.pf-burger-btn');
@@ -174,14 +175,13 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
if(burgerBtn && mobileMenu && overlay && closeBtn) {
burgerBtn.addEventListener('click', openMenu);
closeBtn.addEventListener('click', closeMenu);
// Fermer le menu si on clique en dehors (sur l'overlay sombre)
overlay.addEventListener('click', closeMenu);
}
});
<?php endif; ?>
/**
* pachaFetch : Utilitaire de requête robuste
* pachaFetch : Utilitaire de requête robuste avec gestion Offline Cross-Browser
*/
async function pachaFetch(url, options = {}) {
const finalUrl = url.startsWith('/') ? url.substring(1) : url;
@@ -200,53 +200,133 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
try {
return JSON.parse(rawText);
} catch (jsonErr) {
console.error("Réponse corrompue (HTML reçu au lieu de JSON) :", rawText);
console.error("Réponse corrompue (HTML) :", rawText);
throw new Error("Erreur serveur : format JSON invalide.");
}
} catch (err) {
// Si hors ligne ET modification (POST/PUT/DELETE)
if (!navigator.onLine && options.method && options.method.toUpperCase() !== 'GET') {
await enqueueOfflineAction(finalUrl, options);
// Chrome/Android : Déclenche le Background Sync
if ('serviceWorker' in navigator && 'SyncManager' in window) {
navigator.serviceWorker.ready.then(registration => {
registration.sync.register('househub-sync').catch(() => {});
});
}
showToast("Mémorisé hors ligne 📡", "success");
return { success: true, offline: true };
}
console.error(`Erreur pachaFetch [${finalUrl}] :`, err);
throw err;
}
}
/**
* UI Utility : Toasts (Notifications)
*/
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `pf-toast pf-toast--${type}`;
toast.innerHTML = message;
document.body.appendChild(toast);
* Utilitaire PWA : Ajout à la file d'attente (IndexedDB)
*/
function enqueueOfflineAction(url, options) {
return new Promise((resolve, reject) => {
const request = indexedDB.open('househub-offline-sync', 1);
request.onupgradeneeded = event => {
event.target.result.createObjectStore('sync-queue', { autoIncrement: true });
};
request.onsuccess = event => {
const db = event.target.result;
const tx = db.transaction('sync-queue', 'readwrite');
const store = tx.objectStore('sync-queue');
// Animation et suppression
setTimeout(() => toast.classList.add('is-visible'), 100);
setTimeout(() => {
toast.classList.remove('is-visible');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
let bodyData = options.body;
if (bodyData instanceof FormData) {
bodyData = new URLSearchParams(bodyData).toString();
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
/**
* UI Utility : Confirmation stylisée (Remplace confirm())
*/
async function pachaConfirm(title, message) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'pf-modal open';
modal.innerHTML = `
<div class="pf-modal-content" style="max-width: 400px; align-self: center;">
<h3 style="margin-top:0;">${title}</h3>
<p style="color:var(--text-muted);">${message}</p>
<div class="modal-footer">
<button class="pf-btn btn-secondary" id="confirm-cancel">${tr('btn_cancel')}</button>
<button class="pf-btn" id="confirm-ok" style="background:var(--danger);">${tr('btn_delete')}</button>
</div>
</div>
`;
document.body.appendChild(modal);
store.add({
url: url,
method: options.method,
headers: options.headers,
body: bodyData,
timestamp: Date.now()
});
resolve();
};
request.onerror = event => reject(event.target.error);
});
}
document.getElementById('confirm-cancel').onclick = () => { modal.remove(); resolve(false); };
document.getElementById('confirm-ok').onclick = () => { modal.remove(); resolve(true); };
/**
* Fallback pour Firefox et iOS Safari (Dépilement manuel au retour réseau)
*/
window.addEventListener('online', () => {
const request = indexedDB.open('househub-offline-sync', 1);
request.onsuccess = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains('sync-queue')) return;
const tx = db.transaction('sync-queue', 'readonly');
const store = tx.objectStore('sync-queue');
const getAll = store.getAll();
getAll.onsuccess = async () => {
const actions = getAll.result;
if (actions && actions.length > 0) {
showToast("Synchronisation des données... 🔄", "success");
for (const action of actions) {
try {
await fetch(action.url, {
method: action.method,
headers: action.headers,
body: action.body
});
const delTx = db.transaction('sync-queue', 'readwrite');
delTx.objectStore('sync-queue').delete(action.id);
} catch (e) {
console.error("Échec de la synchro", e);
break;
}
}
showToast("Synchronisation terminée ! ✅", "success");
// Rafraîchissement léger recommandé après synchro de masse
setTimeout(() => window.location.reload(), 1500);
}
};
};
});
}
</script>
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 = `
<div class="pf-modal-content" style="max-width: 400px; align-self: center;">
<h3 style="margin-top:0;">${title}</h3>
<p style="color:var(--text-muted);">${message}</p>
<div class="modal-footer">
<button class="pf-btn btn-secondary" id="confirm-cancel">${tr('btn_cancel')}</button>
<button class="pf-btn" id="confirm-ok" style="background:var(--danger);">${tr('btn_delete')}</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById('confirm-cancel').onclick = () => { modal.remove(); resolve(false); };
document.getElementById('confirm-ok').onclick = () => { modal.remove(); resolve(true); };
});
}
</script>
+5
View File
@@ -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',
];
+5
View File
@@ -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',
];
+5
View File
@@ -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',
];
+21
View File
@@ -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"
}
]
}
+28 -28
View File
@@ -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,12 +384,9 @@ 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';
// --- INJECTION DU DÉTAIL DYNAMIQUE ---
if (!empty($est['details'])) {
$tooltip .= "\n\n📊 Détail du calcul :\n" . $est['details'];
}
// 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;
$pending_charges[] = [
'name' => 'Reste ' . $est['name'],
+21
View File
@@ -0,0 +1,21 @@
<?php
// offline.php
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = tr('offline_title');
$activePage = "offline";
require __DIR__ . '/header.php';
?>
<div class="pf-container" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; text-align: center; padding: 20px;">
<div style="font-size: 5rem; margin-bottom: 20px; filter: grayscale(1); opacity: 0.7;">📡</div>
<h1 style="color: var(--text-main); margin-bottom: 10px;"><?= tr('offline_heading') ?></h1>
<p style="color: var(--text-muted); font-size: 1.1rem; max-width: 400px; margin-bottom: 30px;">
<?= tr('offline_message') ?>
</p>
<button onclick="window.location.reload();" class="pf-btn">
🔄 <?= tr('offline_retry_btn') ?>
</button>
</div>
<?php require __DIR__ . '/footer.php'; ?>
+153
View File
@@ -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);
}),
);
});