13 Commits
Author SHA1 Message Date
FeFeClochette 536f479423 fix holidfays
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-31 13:14:50 +02:00
FeFeClochette 685c649e7c update holidays photo
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-30 18:17:03 +02:00
FeFeClochette 074d8e12d2 fix
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-30 12:50:12 +02:00
FeFeClochette f748330a1b gps expense
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-29 14:58:50 +02:00
FeFeClochette ebdbee71db planning mobil
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-28 16:37:39 +02:00
FeFeClochette 659b286967 imprv roadtrip
Deploy HouseHub / deploy (push) Successful in 3s
2026-07-28 08:51:55 +02:00
FeFeClochette ac8ebf7328 update road trip
Deploy HouseHub / deploy (push) Successful in 1s
2026-07-24 17:55:56 +02:00
FeFeClochette 49d4ec7c35 update calendar
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-22 18:36:08 +02:00
FeFeClochette c48a6ba116 pf_holidays
Deploy HouseHub / deploy (push) Successful in 4s
2026-07-20 20:39:26 +02:00
FeFeClochette 20d6c9b7b0 provisions
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-07 21:26:44 +02:00
FeFeClochette d856d0a409 Deploy from parent_id to production
Deploy HouseHub / deploy (push) Successful in 4s
2026-07-01 10:30:34 +02:00
FeFeClochette 86ef3ccbf2 Deploy from parent_id to production
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-24 21:32:27 +02:00
percoandClaude Sonnet 4.6 3e98de9c18 fix(memo): supprime loading=lazy sur les images en vue note
Deploy HouseHub / deploy (push) Successful in 2s
loading="lazy" dans un conteneur parent avec overflow:hidden empêche
le browser de détecter les images comme visibles → elles ne chargent pas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:23:38 +02:00
30 changed files with 30173 additions and 1945 deletions
+7 -1
View File
@@ -59,12 +59,18 @@ require __DIR__ . '/header.php';
<span class="tab-icon">📊</span> <span class="tab-icon">📊</span>
<span><?= tr('budget_tab_recap') ?></span> <span><?= tr('budget_tab_recap') ?></span>
</a> </a>
<a href="?tab=provisions" class="tab-item <?= $tab == 'provisions' ? 'active' : '' ?>">
<span class="tab-icon">🧮</span>
<span><?= tr('budget_tab_provisions') ?></span>
</a>
</nav> </nav>
</div> </div>
<section class="pf-section"> <section class="pf-section">
<?php <?php
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev']; $allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev', 'provisions'];
if (in_array($tab, $allowedTabs)) { if (in_array($tab, $allowedTabs)) {
$viewPath = __DIR__ . "/modules/budget/views/" . $tab . ".php"; $viewPath = __DIR__ . "/modules/budget/views/" . $tab . ".php";
+14 -1
View File
@@ -172,6 +172,18 @@ CREATE TABLE IF NOT EXISTS pf_expenses (
salary_id INT DEFAULT NULL -- 🔥 AJOUT : Lien vers le salaire configuré salary_id INT DEFAULT NULL -- 🔥 AJOUT : Lien vers le salaire configuré
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `pf_expected_expenses` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`title` VARCHAR(150) NOT NULL,
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`expected_date` DATE NOT NULL,
`is_paid` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_expected_date` (`expected_date`),
INDEX `idx_is_paid` (`is_paid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS pf_alloc_categories ( CREATE TABLE IF NOT EXISTS pf_alloc_categories (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
@@ -297,7 +309,8 @@ CREATE TABLE IF NOT EXISTS pf_holidays (
budget_extra DECIMAL(10,2) DEFAULT 0, budget_extra DECIMAL(10,2) DEFAULT 0,
notes TEXT DEFAULT NULL, notes TEXT DEFAULT NULL,
vehicle_id INT DEFAULT NULL, vehicle_id INT DEFAULT NULL,
return_step_id INT DEFAULT NULL, -- 🔥 NOUVEAU : Point de bascule du retour return_step_id INT DEFAULT NULL,
image_url VARCHAR(500) DEFAULT NULL,
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+6 -5
View File
@@ -441,10 +441,9 @@ require __DIR__ . '/header.php';
<span style="font-size:1.1rem; line-height:1; margin-right:6px;">🏫</span> <span style="font-size:1.1rem; line-height:1; margin-right:6px;">🏫</span>
<span><?= htmlspecialchars($mode) ?></span> <span><?= htmlspecialchars($mode) ?></span>
</div> </div>
<?php else: <?php else: ?>
$hue = ($index * 137) % 360; ?>
<div class="pf-legend-item"> <div class="pf-legend-item">
<div class="pf-legend-color" style="background: hsl(<?= $hue ?>, 70%, 50%);"></div> <div class="pf-legend-color" style="background: var(--primary);"></div>
<span><?= htmlspecialchars($mode) ?></span> <span><?= htmlspecialchars($mode) ?></span>
</div> </div>
<?php endif; <?php endif;
@@ -452,7 +451,8 @@ require __DIR__ . '/header.php';
<!-- Enfants Malades (Dynamique avec couleur BDD) --> <!-- Enfants Malades (Dynamique avec couleur BDD) -->
<?php foreach ($kids as $kid): <?php foreach ($kids as $kid):
$color = !empty($kid['color']) ? $kid['color'] : 'var(--danger)'; // Fallback aligné exactement sur celui du JS (#e11d48)
$color = !empty($kid['color']) ? $kid['color'] : '#e11d48';
?> ?>
<div class="pf-legend-item"> <div class="pf-legend-item">
<div class="pf-legend-color" style="background: <?= htmlspecialchars($color) ?>; opacity: 0.8;"></div> <div class="pf-legend-color" style="background: <?= htmlspecialchars($color) ?>; opacity: 0.8;"></div>
@@ -477,5 +477,6 @@ window.FAMILY_CONFIG = {
} }
}; };
</script> </script>
<script src="/modules/family-calendar/family-calendar.js"></script>
<script src="/modules/family-calendar/family-calendar.js?v=<?= time() ?>"></script>
<?php require __DIR__ . '/footer.php'; ?> <?php require __DIR__ . '/footer.php'; ?>
+13 -2
View File
@@ -34,7 +34,18 @@ if ($tab === 'holiday_detail' && isset($_GET['id'])) {
require __DIR__ . '/modules/holidays/views/list.php'; require __DIR__ . '/modules/holidays/views/list.php';
} }
// 4. Inclusion du JS global du module (Pont i18n déjà géré dans le header) // 4. Inclusion des librairies globales du module (Flatpickr)
echo '<script src="/modules/holidays/holidays.js"></script>'; ?>
<!-- Flatpickr (Vanilla JS Date Range) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css" id="flatpickr-dark-theme" disabled>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/fr.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/cat.js"></script>
<?php
$jsPath = __DIR__ . '/modules/holidays/holidays.js';
$jsVersion = file_exists($jsPath) ? filemtime($jsPath) : time();
echo '<script src="/modules/holidays/holidays.js?v=' . $jsVersion . '"></script>';
require __DIR__ . '/footer.php'; require __DIR__ . '/footer.php';
+45 -38
View File
@@ -1,52 +1,59 @@
<?php <?php
// includes/db.php
if (file_exists(__DIR__ . '/config.php')) {
require_once __DIR__ . '/config.php';
}
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
session_start(); session_start();
} }
$host = getenv('DB_HOST') ?: 'househub-db'; $envHost = getenv('DB_HOST');
$user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme';
$db = $_SESSION['family_db'] ?? null;
if (!$db) { // 1. Détection stricte de l'environnement
$current = basename($_SERVER['PHP_SELF'] ?? ''); if ($envHost === 'househub-db') {
if (!in_array($current, ['login.php', 'register.php'])) { // Docker
header('Location: /login.php'); $host = 'househub-db';
exit; $user = getenv('DB_USER') ?: 'househub';
} $pass = getenv('DB_PASS') ?: 'changeme';
return; } else {
// XAMPP
$host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
$user = 'root';
$pass = '';
} }
// 2. Multi-tenant strict : cibler la DB de la famille courante
$db = 'househub_meta'; // Fallback par défaut
if (!empty($_SESSION['user']['family_id'])) {
$db = 'househub_f' . (int)$_SESSION['user']['family_id'];
}
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30,
];
try { try {
$pdo = new PDO( $pdo = new PDO($dsn, $user, $pass, $options);
"mysql:host=$host;dbname=$db;charset=utf8mb4",
$user, $pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30,
]
);
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
try { // Forcer la collation
$foyer = $pdo->query("SELECT currency, zone_scolaire FROM pf_foyer_settings WHERE id = 1")->fetch(); $pdo->exec("SET collation_connection = utf8mb4_general_ci");
if (!defined('CURRENCY')) { $pdo->exec("SET collation_database = utf8mb4_general_ci");
define('CURRENCY', $foyer['currency'] ?? '€'); $pdo->exec("SET collation_server = utf8mb4_general_ci");
}
if (!defined('ZONE_SCOLAIRE')) {
define('ZONE_SCOLAIRE', $foyer['zone_scolaire'] ?? 'C');
}
} catch (\PDOException $e) {
if (!defined('CURRENCY')) define('CURRENCY', '€');
if (!defined('ZONE_SCOLAIRE')) define('ZONE_SCOLAIRE', 'C');
}
// ------------------------------------------------
} catch (\PDOException $e) { } catch (\PDOException $e) {
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); } die("Erreur de connexion BDD (Host: $host, DB: $db) : " . $e->getMessage());
die(json_encode(['ok' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]));
} }
?> ?>
+21
View File
@@ -775,6 +775,27 @@ return [
'bud_sav_selection' => 'Selecció:', 'bud_sav_selection' => 'Selecció:',
'bud_sav_ph_name' => 'Nom del compte', 'bud_sav_ph_name' => 'Nom del compte',
// --- Module Budget : Provisions & Optimisation ---
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
'btn_optimize_cashflow' => 'Optimiser mes Économies',
'add_new_provision' => 'Anticiper une grosse dépense à venir',
'provision_label' => 'Libellé de la dépense',
'provision_placeholder_wood' => 'Ex: Commande de bois, Révision de l\'auto...',
'amount' => 'Montant',
'expected_date' => 'Date prévue',
'btn_add' => 'Ajouter',
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
'error_generic' => 'Une erreur est survenue',
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
'btn_delete' => 'Supprimer',
'savings_this_month' => 'Économies globales à placer ce mois-ci',
'calculate' => 'Calculer la stratégie optimale',
'date_past_warning' => 'La date sélectionnée est déjà passée !',
'budget_tab_provisions' => 'Provisions',
// JS & Errors // JS & Errors
'bud_sav_confirm_delete_month' => 'Estàs segur que vols suprimir totes les dades de %m per a %o?', 'bud_sav_confirm_delete_month' => 'Estàs segur que vols suprimir totes les dades de %m per a %o?',
'bud_sav_prompt_duplicate' => "Vols duplicar les dades de %s cap a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:", 'bud_sav_prompt_duplicate' => "Vols duplicar les dades de %s cap a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
+20
View File
@@ -824,6 +824,26 @@ return [
'bud_prev_target_ph' => 'E.g., 150', 'bud_prev_target_ph' => 'E.g., 150',
'bud_prev_transfer_to' => 'To', 'bud_prev_transfer_to' => 'To',
// --- Module Budget : Provisions & Optimisation ---
'budget_provisions_title' => 'Provisions & Cashflow Optimization',
'btn_optimize_cashflow' => 'Optimize My Savings',
'add_new_provision' => 'Anticipate an upcoming major expense',
'provision_label' => 'Expense description',
'provision_placeholder_wood' => 'E.g., Firewood order, Car service...',
'amount' => 'Amount',
'expected_date' => 'Expected date',
'btn_add' => 'Add',
'upcoming_provisions_list' => 'Scheduled expenses',
'optimization_assistant_title' => 'Fortnightly Interest Optimization Assistant',
'error_generic' => 'An error occurred',
'success_add_provision' => 'Forecasted expense added successfully!',
'no_provisions' => 'No forecasted expenses registered at the moment.',
'btn_delete' => 'Delete',
'savings_this_month' => 'Total savings to allocate this month',
'calculate' => 'Calculate optimal strategy',
'date_past_warning' => 'The selected date has already passed!',
'budget_tab_provisions' => 'Forecasts',
// ========================================== // ==========================================
// MODULE: GIFTS (gift-list) // MODULE: GIFTS (gift-list)
// ========================================== // ==========================================
+28
View File
@@ -773,6 +773,34 @@ return [
'bud_sav_ph_name' => 'Nom du compte', 'bud_sav_ph_name' => 'Nom du compte',
// --- BUDGET : PREVISION ---
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
'btn_optimize_cashflow' => 'Optimiser mes Économies',
'add_new_provision' => 'Anticiper une grosse dépense à venir',
'provision_label' => 'Libellé de la dépense',
'provision_placeholder_wood' => 'Ex: Commande bois de chauffage, Révision voiture...',
'amount' => 'Montant',
'expected_date' => 'Date prévue',
'btn_add' => 'Ajouter',
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
'error_generic' => 'Une erreur est survenue',
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
'btn_delete' => 'Supprimer',
'savings_this_month' => 'Économies globales à placer ce mois-ci',
'calculate' => 'Calculer la stratégie optimale',
'date_past_warning' => 'La date sélectionnée est déjà passée !',
'budget_tab_provisions' => 'Provisions',
'budget_opti_base' => 'Base',
'budget_opti_debt' => 'Dette compensée',
'budget_opti_no_config' => 'Aucune configuration de salaire trouvée pour cette année.',
'budget_opti_recap_apports' => 'Résumé des apports (Clearing) :',
'budget_opti_total_disp' => 'Total disponible pour l\'optimisation :',
'budget_opti_must_pay' => 'doit verser',
// JS & Erreurs // JS & Erreurs
'bud_sav_confirm_delete_month' => 'Voulez-vous vraiment supprimer toutes les données de %m pour %o ?', 'bud_sav_confirm_delete_month' => 'Voulez-vous vraiment supprimer toutes les données de %m pour %o ?',
'bud_sav_prompt_duplicate' => "Voulez-vous dupliquer les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :", 'bud_sav_prompt_duplicate' => "Voulez-vous dupliquer les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
+20 -4
View File
@@ -1,9 +1,23 @@
<?php <?php
// includes/meta_db.php
// Connexion à la base meta (gestion des familles et utilisateurs) // Connexion à la base meta (gestion des familles et utilisateurs)
$meta_host = getenv('DB_HOST') ?: 'househub-db';
$meta_db = 'househub_meta'; $envHost = getenv('DB_HOST');
$meta_user = getenv('DB_USER') ?: 'househub';
$meta_pass = getenv('DB_PASS') ?: 'changeme'; // Détection stricte : Docker injecte toujours 'househub-db' via le docker-compose
if ($envHost === 'househub-db') {
// Environnement Docker (Ton collègue ou la Prod)
$meta_host = 'househub-db';
$meta_user = getenv('DB_USER') ?: 'househub';
$meta_pass = getenv('DB_PASS') ?: 'changeme';
} else {
// Environnement Local XAMPP (Toi)
$meta_host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
$meta_user = 'root';
$meta_pass = '';
}
$meta_db = 'househub_meta';
try { try {
$meta_pdo = new PDO( $meta_pdo = new PDO(
@@ -14,8 +28,10 @@ try {
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
] ]
); );
} catch (\PDOException $e) { } catch (\PDOException $e) {
die(json_encode(['error' => 'Meta DB unavailable: ' . $e->getMessage()])); die(json_encode(['error' => 'Meta DB unavailable: ' . $e->getMessage()]));
} }
?>
+28 -29
View File
@@ -1,49 +1,48 @@
<?php <?php
require_once __DIR__ . '/includes/meta_db.php'; require_once __DIR__ . '/includes/meta_db.php';
$db_host = getenv('DB_HOST') ?: '127.0.0.1'; // Récupération des identifiants de BDD depuis l'environnement (fallback par défaut)
$db_host = getenv('DB_HOST') ?: 'househub-db';
$db_user = getenv('DB_USER') ?: 'househub'; $db_user = getenv('DB_USER') ?: 'househub';
$db_pass = getenv('DB_PASS') ?: 'househub_dev'; $db_pass = getenv('DB_PASS') ?: 'changeme';
echo "<h1>🗺 Migration : Refonte Modèle Voyages</h1><ul>"; echo "<h1>🛠 HouseHub - Correction AUTO_INCREMENT Voyages</h1>";
try { try {
$stmt = $meta_pdo->query("SELECT db_name, name FROM families WHERE is_active = 1"); // On récupère toutes les familles actives
$stmt = $meta_pdo->query("SELECT db_name, name FROM families");
$families = $stmt->fetchAll(PDO::FETCH_ASSOC); $families = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($families as $family) { foreach ($families as $f) {
$dbName = $family['db_name']; $dbName = $f['db_name'];
echo "<h3>Famille : {$f['name']} ($dbName)</h3><ul>";
try { try {
$pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); $pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
// 1. Mise à jour pf_holidays // 1. On vérifie si la clé primaire existe déjà pour éviter de faire planter le script
$pdo->exec("ALTER TABLE pf_holidays ADD COLUMN return_step_id INT DEFAULT NULL"); $hasPrimaryKey = $pdo->query("SHOW KEYS FROM pf_holidays WHERE Key_name = 'PRIMARY'")->fetch();
// 2. Mise à jour pf_holidays_items if (!$hasPrimaryKey) {
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN step_type VARCHAR(20) DEFAULT 'stop'"); $pdo->exec("ALTER TABLE pf_holidays ADD PRIMARY KEY (id)");
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN expense_context VARCHAR(20) DEFAULT NULL"); echo "<li>✅ Clé primaire restaurée sur la colonne `id`.</li>";
}
// 3. Migration des données (is_return -> return_step_id) // 2. On restaure la propriété AUTO_INCREMENT
// On cherche la première étape cochée "is_return" et on l'assigne au voyage $pdo->exec("ALTER TABLE pf_holidays MODIFY id INT(11) NOT NULL AUTO_INCREMENT");
$pdo->exec(" echo "<li>✅ Propriété AUTO_INCREMENT restaurée.</li>";
UPDATE pf_holidays h
SET return_step_id = (
SELECT id FROM pf_holidays_items i
WHERE i.holiday_id = h.id AND i.is_return = 1 AND i.location_name IS NOT NULL
LIMIT 1
)
");
// 4. Nettoyage de l'ancienne colonne
$pdo->exec("ALTER TABLE pf_holidays_items DROP COLUMN is_return");
echo "<li>✅ {$family['name']} : Schéma Voyages mis à jour !</li>";
} catch (\PDOException $e) { } catch (\PDOException $e) {
echo "<li>⚠️ {$family['name']} : " . $e->getMessage() . "</li>"; // On attrape l'erreur si la modif a déjà été faite
echo "<li>️ Action ignorée ou table déjà à jour : " . $e->getMessage() . "</li>";
} }
echo "</ul>";
} }
echo "</ul><h2>🎉 Migration terminée !</h2>"; echo "<h2>🚀 Correction terminée ! Tu peux réessayer de créer un voyage.</h2>";
} catch (Exception $e) { } catch (Exception $e) {
die("Erreur fatale : " . $e->getMessage()); die("Erreur fatale de connexion à la Meta DB : " . $e->getMessage());
} }
?> ?>
+118
View File
@@ -1798,3 +1798,121 @@ body.sum-mode-active .sum-target {
padding: 12px 10px; padding: 12px 10px;
} }
} }
/* ==========================================================================
14. PROVISIONS & OPTIMISATION (CASHFLOW)
========================================================================== */
/* Formulaire d'ajout */
.provisions-form-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto;
gap: 15px;
align-items: end;
}
.provisions-form-grid .pf-form-group {
margin-bottom: 0;
}
.provisions-form-grid .pf-btn {
height: 42px;
margin: 0;
}
/* Tableau des provisions */
.budget-table-card {
background: var(--bg-panel);
border-radius: 16px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
overflow: hidden;
margin-bottom: 30px;
}
.provisions-table th {
text-align: center;
}
.provisions-table th:first-child {
text-align: left;
padding-left: 15px;
}
.provisions-table th:last-child {
text-align: right;
padding-right: 15px;
}
.provisions-table td {
border-bottom: 1px solid var(--border-light);
}
.prov-title-cell {
font-weight: 500;
color: var(--text-main);
padding-left: 15px;
}
.prov-amount-cell {
color: #2563eb;
font-weight: bold;
text-align: center;
}
.prov-date-cell {
color: var(--text-muted);
text-align: center;
}
/* Modale d'optimisation */
.provisions-modal-content {
max-width: 600px;
width: 95%;
max-height: 85vh;
overflow-y: auto;
}
.provision-person-card {
background: var(--bg-subtle);
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border-light);
margin-bottom: 10px;
}
.provision-person-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.provision-input-group {
display: flex;
align-items: center;
gap: 10px;
}
.input-amount-highlight {
font-weight: bold;
color: #2563eb !important;
}
.input-amount-success {
font-weight: bold;
color: var(--success) !important;
flex: 1;
margin: 0;
}
.currency-symbol {
font-weight: bold;
color: var(--text-muted);
}
.optimization-hint {
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
margin-top: 15px;
}
.pf-divider {
border: 0;
border-top: 1px solid var(--border-light);
margin: 20px 0;
}
.btn-block {
width: 100%;
}
/* --- RESPONSIVE --- */
@media (max-width: 768px) {
.provisions-form-grid {
grid-template-columns: 1fr;
gap: 12px;
}
}
@@ -0,0 +1,30 @@
<?php
header('Content-Type: application/json');
// On charge l'authentification et l'init PDO de l'espace familial courant automatiquement via tes includes globaux
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$title = trim($_POST['title'] ?? '');
$amount = floatval($_POST['amount'] ?? 0);
$expected_date = $_POST['expected_date'] ?? '';
if (empty($title) || $amount <= 0 || empty($expected_date)) {
echo json_encode(['success' => false, 'message' => tr('error_generic')]);
exit;
}
try {
// Grâce au multi-tenant par BDD, $pdo pointe déjà sur la base de la famille connectée
$stmt = $pdo->prepare("INSERT INTO pf_expected_expenses (title, amount, expected_date) VALUES (?, ?, ?)");
$stmt->execute([$title, $amount, $expected_date]);
echo json_encode(['success' => true, 'message' => tr('success_add_provision')]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]);
}
@@ -0,0 +1,19 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
$id = intval($_POST['id'] ?? 0);
if (!$id) {
echo json_encode(['success' => false, 'message' => 'Invalid ID']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM pf_expected_expenses WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
@@ -0,0 +1,35 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.php';
try {
// Récupérer les provisions non payées, classées par date
$stmt = $pdo->query("SELECT id, title, amount, expected_date FROM pf_expected_expenses WHERE is_paid = 0 ORDER BY expected_date ASC");
$provisions = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($provisions)) {
$html = '<p class="pf-text-muted">' . tr('no_provisions') . '</p>';
} else {
$html = '<table class="pf-table" style="width:100%; border-collapse: collapse;">';
$html .= '<thead style="border-bottom: 2px solid var(--border-color); text-align: left;">';
$html .= '<tr><th>' . tr('provision_label') . '</th><th>' . tr('amount') . '</th><th>' . tr('expected_date') . '</th><th style="text-align:right;">Actions</th></tr>';
$html .= '</thead><tbody>';
foreach ($provisions as $p) {
$dateFormated = date('d/m/Y', strtotime($p['expected_date']));
$html .= '<tr style="border-bottom: 1px solid var(--border-color); height: 45px;">';
$html .= '<td>' . htmlspecialchars($p['title']) . '</td>';
$html .= '<td>' . number_format($p['amount'], 2, ',', ' ') . ' €</td>';
$html .= '<td>' . $dateFormated . '</td>';
$html .= '<td style="text-align:right;"><button class="pf-btn pf-btn-danger btn-delete-provision" data-id="' . $p['id'] . '">' . tr('btn_delete') . '</button></td>';
$html .= '</tr>';
}
$html .= '</tbody></table>';
}
echo json_encode(['success' => true, 'html' => $html]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
@@ -0,0 +1,156 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.php';
$savings_inputs = $_POST['savings'] ?? [];
$current_year = date('Y');
$current_month = date('m');
try {
// 1. Récupération des dépenses prévues
$stmtExpenses = $pdo->prepare("
SELECT title, amount, expected_date
FROM pf_expected_expenses
WHERE is_paid = 0
AND YEAR(expected_date) = ?
AND MONTH(expected_date) = ?
ORDER BY expected_date ASC
");
$stmtExpenses->execute([$current_year, $current_month]);
$expenses = $stmtExpenses->fetchAll(PDO::FETCH_ASSOC);
$q1_expenses_total = 0;
$q2_expenses_total = 0;
$detail_html = '<ul style="padding-left: 1.2rem; margin-bottom: 1rem; color: var(--text-muted); font-size:0.9rem;">';
foreach ($expenses as $e) {
$day = intval(date('d', strtotime($e['expected_date'])));
if ($day <= 15) {
$q1_expenses_total += $e['amount'];
} else {
$q2_expenses_total += $e['amount'];
}
$detail_html .= '<li>' . htmlspecialchars($e['title']) . ' (' . number_format($e['amount'], 2, ',', ' ') . ' € le ' . date('d/m', strtotime($e['expected_date'])) . ')</li>';
}
$detail_html .= '</ul>';
$total_expenses = $q1_expenses_total + $q2_expenses_total;
// 2. Récupération des dettes
$stmtDebts = $pdo->query("SELECT payer, SUM(amount) as total_debt FROM pf_advances WHERE is_resolved = 0 GROUP BY payer");
$debts = $stmtDebts->fetchAll(PDO::FETCH_KEY_PAIR);
// 3. NOUVELLE LOGIQUE : Clearing intelligent (Compensation partielle ou totale)
$total_base_inputs = array_sum(array_map('floatval', $savings_inputs));
$surplus_for_clearing = $total_base_inputs - $total_expenses;
$monthly_savings = 0; // Le vrai total qui sera viré après déduction des dettes
$contributions_html = '<ul style="padding-left: 1.2rem; margin-bottom: 0.5rem; color: var(--text-main); font-size:0.9rem; line-height: 1.6;">';
foreach ($savings_inputs as $person => $amount) {
$base_amount = floatval($amount);
$debt = $debts[$person] ?? 0;
// Formatage bancaire propre pour les textes
$base_fmt = number_format($base_amount, 2, ',', ' ');
if ($debt > 0) {
if ($surplus_for_clearing > 0) {
$compensation = min($debt, $base_amount, $surplus_for_clearing);
$net_amount = $base_amount - $compensation;
$surplus_for_clearing -= $compensation;
$monthly_savings += $net_amount;
$comp_fmt = number_format($compensation, 2, ',', ' ');
$net_fmt = number_format($net_amount, 2, ',', ' ');
$reste_dette = $debt - $compensation;
$txt_reste = ($reste_dette > 0)
? ' <span style="color:var(--danger); font-size:0.8rem; margin-left: 5px;">(Reste ' . number_format($reste_dette, 2, ',', ' ') . ' € de dette)</span>'
: ' <span style="color:var(--success); font-weight:bold; font-size:0.85rem; margin-left: 5px;">(Dette soldée 🎉)</span>';
// Harmonisation en bleu (#2563eb) pour le montant net à verser
$contributions_html .= '<li style="margin-bottom: 5px;"><strong>' . htmlspecialchars($person) . '</strong> : Base ' . $base_fmt . ' € Remboursé ' . $comp_fmt . ' € = <strong style="color:#2563eb;">' . $net_fmt . ' € à verser</strong>' . $txt_reste . '</li>';
} else {
$monthly_savings += $base_amount;
$contributions_html .= '<li style="margin-bottom: 5px;"><strong>' . htmlspecialchars($person) . '</strong> : <strong style="color:#2563eb;">' . $base_fmt . ' € à verser</strong> <span style="color:var(--danger); font-size:0.8rem; margin-left: 5px;">(Dette gelée, liquidités insuffisantes)</span></li>';
}
} else {
$monthly_savings += $base_amount;
$contributions_html .= '<li style="margin-bottom: 5px;"><strong>' . htmlspecialchars($person) . '</strong> : <strong style="color:#2563eb;">' . $base_fmt . ' € à verser</strong></li>';
}
}
$contributions_html .= '</ul>';
$contributions_html .= '<p style="color: var(--text-main); font-weight: bold; margin-top:5px; font-size:0.95rem;">Total net atterrissant sur les comptes : ' . number_format($monthly_savings, 2, ',', ' ') . ' €</p>';
if(empty($expenses)) {
$detail_html = '<p style="color: var(--success); font-weight:bold; margin-bottom:1rem; font-size:0.9rem;">🎉 Aucune grosse dépense enregistrée sur ce mois.</p>';
}
// --- MOTEUR DE RÈGLES DES QUINZAINES (Remplacement des ** par <strong>) ---
$instructions = [];
$remaining_to_allocate = $monthly_savings;
// Règle 1 : Première quinzaine
if ($q1_expenses_total > 0) {
if ($remaining_to_allocate >= $q1_expenses_total) {
$remaining_to_allocate -= $q1_expenses_total;
$instructions[] = "💼 <strong>Dès le 1er</strong> : Laissez <strong>" . number_format($q1_expenses_total, 2, ',', ' ') . " €</strong> sur le compte commun pour honorer les dépenses de la 1ère quinzaine.";
} else {
$deficit = $q1_expenses_total - $remaining_to_allocate;
$remaining_to_allocate = 0;
$instructions[] = "💼 <strong>Dès le 1er</strong> : Gardez l'intégralité des apports sur le compte commun.";
$instructions[] = "⚠️ <strong>Retrait requis</strong> : Retirez <strong>" . number_format($deficit, 2, ',', ' ') . " €</strong> depuis le Livret A vers le commun pour couvrir la 1ère quinzaine.";
}
}
// Règle 2 : Deuxième quinzaine
if ($q2_expenses_total > 0) {
if ($remaining_to_allocate >= $q2_expenses_total) {
$remaining_to_allocate -= $q2_expenses_total;
$instructions[] = "📈 <strong>Dès le 1er</strong> : Placez <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> sur le Livret A pour générer des intérêts sur la 1ère quinzaine.";
$instructions[] = "🔄 <strong>Le 16 du mois</strong> : Transférez ces <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> sur le compte commun pour payer la dépense.";
} else {
if ($remaining_to_allocate > 0) {
$instructions[] = "📈 <strong>Dès le 1er</strong> : Placez le reste des apports (<strong>" . number_format($remaining_to_allocate, 2, ',', ' ') . " €</strong>) sur le Livret A.";
$instructions[] = "🔄 <strong>Le 16 du mois</strong> : Retirez <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> global du Livret A pour payer la fin de mois.";
$remaining_to_allocate = 0;
} else {
$instructions[] = "🔄 <strong>Le 16 du mois</strong> : Retirez <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> du Livret A vers le commun. <em>Ne le faites pas avant le 16 !</em>";
}
}
}
// Solde résiduel long terme
if ($remaining_to_allocate > 0) {
$instructions[] = "💰 <strong>Épargne stable</strong> : Placez les <strong>" . number_format($remaining_to_allocate, 2, ',', ' ') . " €</strong> restants sur votre Livret A dès le 1er.";
}
// --- CONSTITUTION DU RENDU HTML FINAL ---
$html = '<div style="border-top: 1px dashed var(--border-color); padding-top:0.8rem;">';
// Titre de l'état du Clearing simplifié et clair
$html .= '<h4 style="margin:0 0 0.4rem 0; font-size:0.95rem;">🤝 Résumé des apports et remboursements internes :</h4>';
$html .= '<div style="background: var(--bg-main); padding: 8px; border-radius: 6px; margin-bottom: 1rem; border: 1px solid var(--border-color);">';
$html .= $contributions_html;
$html .= '</div>';
$html .= '<h4 style="margin:0 0 0.4rem 0; font-size:0.95rem;">Analyse des dépenses du mois :</h4>';
$html .= $detail_html;
$html .= '<div style="background: var(--bg-main); padding: 8px; border-radius: 6px; border-left: 4px solid var(--accent-color);">';
$html .= '<h4 style="margin:0 0 0.5rem 0; color: var(--accent-color); font-size:0.95rem;">📋 Plan d\'action recommandé :</h4>';
foreach ($instructions as $ins) {
$html .= '<p style="margin:0 0 0.4rem 0; font-size: 0.9rem; line-height: 1.4;">' . $ins . '</p>';
}
$html .= '</div></div>';
echo json_encode(['success' => true, 'html' => $html]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'Calcul impossible : ' . $e->getMessage()]);
}
+2 -2
View File
@@ -1,8 +1,8 @@
<?php <?php
// modules/budget/views/budget_prev.php // modules/budget/views/budget_prev.php
// 1. Chargement dynamique des ADULTES de la famille // 1. Chargement dynamique des ADULTES de la famille (Exclusion stricte des rôles secondaires)
$stmtPeople = $pdo->query("SELECT id, name, user_id, role, color FROM pf_people WHERE role NOT IN ('enfant', 'nounou') AND is_active = 1 ORDER BY id ASC"); $stmtPeople = $pdo->query("SELECT id, name, user_id, role, color FROM pf_people WHERE role IN ('parent') AND is_active = 1 ORDER BY id ASC");
$budgetParents = $stmtPeople->fetchAll(PDO::FETCH_ASSOC); $budgetParents = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
// Sécurité : au cas où aucun adulte n'est trouvé, on évite un crash // Sécurité : au cas où aucun adulte n'est trouvé, on évite un crash
+265
View File
@@ -0,0 +1,265 @@
<?php
// modules/budget/views/provisions.php
// --- RÉCUPÉRATION DES DÉPENSES PRÉVUES (MULTI-TENANT PDO) ---
$stmt = $pdo->query("SELECT id, title, amount, expected_date FROM pf_expected_expenses WHERE is_paid = 0 ORDER BY expected_date ASC");
$provisions = $stmt->fetchAll(PDO::FETCH_ASSOC);
// --- RÉCUPÉRATION DES APPORTS ET DETTES (CLEARING) ---
$currentYear = date('Y');
// 1. Apports de base (Eco Family) des parents configurés pour l'année
$stmtConfig = $pdo->prepare("SELECT person, eco_family FROM pf_salary_config WHERE year = ?");
$stmtConfig->execute([$currentYear]);
$configs = $stmtConfig->fetchAll(PDO::FETCH_KEY_PAIR);
// 2. Dettes non résolues par personne (issues des Avances & Tricount)
$stmtDebts = $pdo->query("SELECT payer, SUM(amount) as total_debt FROM pf_advances WHERE is_resolved = 0 GROUP BY payer");
$debts = $stmtDebts->fetchAll(PDO::FETCH_KEY_PAIR);
function formatDate($dateString) {
if (!$dateString) return '';
return date('d/m/Y', strtotime($dateString));
}
?>
<div class="budget-view">
<div class="view-header">
<h2><?= tr('budget_provisions_title') ?></h2>
<button class="pf-btn pf-btn-primary" onclick="openOptimizeModal()">
✨ <?= tr('btn_optimize_cashflow') ?>
</button>
</div>
<div class="pf-card">
<h3 class="pf-card-h2"><?= tr('add_new_provision') ?></h3>
<form id="form-add-provision" data-action="/modules/budget/includes/api/add-provision.php">
<div class="provisions-form-grid">
<div class="pf-form-group">
<label class="pf-label" for="prov-title"><?= tr('provision_label') ?></label>
<input type="text" id="prov-title" name="title" class="pf-input" placeholder="<?= tr('provision_placeholder_wood') ?>" required>
</div>
<div class="pf-form-group">
<label class="pf-label" for="prov-amount"><?= tr('amount') ?> (€)</label>
<input type="number" id="prov-amount" name="amount" class="pf-input no-spinners input-amount-highlight" step="0.01" min="0.01" required>
</div>
<div class="pf-form-group">
<label class="pf-label" for="prov-date"><?= tr('expected_date') ?></label>
<input type="date" id="prov-date" name="expected_date" class="pf-input" required>
</div>
<button type="submit" class="pf-btn pf-btn-secondary">
<?= tr('btn_add') ?>
</button>
</div>
</form>
</div>
<div class="budget-table-card table-responsive">
<?php if (empty($provisions)): ?>
<div class="optimization-hint" style="padding: 30px;">
<p><?= tr('no_provisions') ?></p>
</div>
<?php else: ?>
<table class="pf-table savings-table provisions-table">
<thead>
<tr>
<th><?= tr('provision_label') ?></th>
<th style="width: 150px;"><?= tr('amount') ?></th>
<th style="width: 150px;"><?= tr('expected_date') ?></th>
<th style="width: 100px;"><?= tr('actions') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($provisions as $p): ?>
<tr>
<td class="prov-title-cell">
<?= htmlspecialchars($p['title']) ?>
</td>
<td class="prov-amount-cell">
<?= number_format($p['amount'], 2, ',', ' ') ?> €
</td>
<td class="prov-date-cell">
<?= formatDate($p['expected_date']) ?>
</td>
<td class="text-right" style="padding-right: 15px;">
<button class="btn-icon-action delete btn-safe-click" title="<?= tr('btn_delete') ?>" onclick="deleteProvision(<?= $p['id'] ?>)">
🗑️
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<div id="optimizeModal" class="pf-modal">
<div class="pf-modal-content provisions-modal-content">
<div class="pf-modal-header">
<h3 class="pf-modal-title">🧠 <?= tr('optimization_assistant_title') ?></h3>
<button type="button" class="pf-modal-close" onclick="closeOptimizeModal()">&times;</button>
</div>
<form id="form-optimize" data-action="/modules/budget/includes/api/optimize-cashflow.php">
<div id="dynamic-savings-inputs">
<?php if (empty($configs)): ?>
<p class="text-danger font-bold pf-muted-tiny"><?= tr('budget_opti_no_config') ?></p>
<input type="number" step="0.01" min="0" name="savings[Global]" class="pf-input no-spinners" required>
<?php else: ?>
<?php foreach ($configs as $person => $ecoBase):
$debt = $debts[$person] ?? 0;
?>
<div class="pf-form-group provision-person-card">
<label class="pf-label provision-person-header">
<strong class="text-main"><?= htmlspecialchars($person) ?></strong>
<span class="pf-muted-note">
<?php if ($debt > 0): ?>
<span class="text-danger font-bold">(Dette en attente : <?= (float)$debt ?> €)</span>
<?php endif; ?>
</span>
</label>
<div class="provision-input-group">
<input type="number" step="0.01" min="0" name="savings[<?= htmlspecialchars($person) ?>]" class="pf-input no-spinners input-amount-success" value="<?= (float)$ecoBase ?>" required>
<span class="currency-symbol">€</span>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<button type="submit" class="pf-btn pf-btn-primary btn-block">
<?= tr('calculate') ?>
</button>
</form>
<hr class="pf-divider">
<div id="optimization-results">
<p class="optimization-hint">
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
</p>
</div>
</div>
</div>
<script>
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
'btn_delete': <?= json_encode(tr('btn_delete')) ?>,
'error_generic': <?= json_encode(tr('error_generic')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>
};
// --- 2. GESTION DU FORMULAIRE D'AJOUT ---
const formAdd = document.getElementById('form-add-provision');
if (formAdd) {
formAdd.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerText;
submitBtn.innerText = '⏳';
submitBtn.disabled = true;
try {
const result = await pachaFetch(this.getAttribute('data-action'), {
method: 'POST',
body: new FormData(this)
});
if (result.success) {
window.location.reload();
} else {
alert(result.message || window.I18N['error_generic']);
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
} catch (error) {
alert(window.I18N['bud_err_tech']);
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
});
}
// --- 3. GESTION DE LA SUPPRESSION ---
async function deleteProvision(id) {
if (!confirm(window.I18N['btn_delete'] + ' ?')) return;
try {
const result = await pachaFetch('/modules/budget/includes/api/delete-provision.php', {
method: "POST",
body: new URLSearchParams({ id: id })
});
if (result.success) {
window.location.reload();
} else {
alert(result.message);
}
} catch(err) {
alert(window.I18N['bud_err_tech']);
}
}
// --- 4. CONTROL DE LA MODALE D'OPTIMISATION ---
function openOptimizeModal() {
document.getElementById('optimization-results').innerHTML = `
<p class="optimization-hint">
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
</p>`;
document.getElementById('optimizeModal').classList.add('open');
document.body.classList.add('no-scroll');
}
function closeOptimizeModal() {
document.getElementById('optimizeModal').classList.remove('open');
document.body.classList.remove('no-scroll');
}
window.onclick = function(event) {
const modal = document.getElementById('optimizeModal');
if (event.target == modal) {
closeOptimizeModal();
}
}
// --- 5. EXECUTION ET SUBMIT DE L'ALGORITHME (formOptimize) ---
const formOptimize = document.getElementById('form-optimize');
if (formOptimize) {
formOptimize.addEventListener('submit', async function(e) {
e.preventDefault();
const resultsContainer = document.getElementById('optimization-results');
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.disabled = true;
resultsContainer.innerHTML = '<div class="optimization-hint">⏳ Analyse des quinzaines bancaires...</div>';
try {
const result = await pachaFetch(this.getAttribute('data-action'), {
method: 'POST',
body: new FormData(this)
});
if (result.success) {
resultsContainer.innerHTML = result.html;
} else {
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${result.message}</p>`;
}
} catch (error) {
console.error("Erreur d'optimisation :", error);
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${window.I18N['bud_err_tech']}</p>`;
} finally {
submitBtn.disabled = false;
}
});
}
</script>
+61 -29
View File
@@ -1289,46 +1289,78 @@ document.addEventListener("DOMContentLoaded", () => {
} }
generateMonthSummaryHTML(year, month) { generateMonthSummaryHTML(year, month) {
const stats = { off: 0, extra: 0, sick: 0, presence: 0 }; // 1. Identifier UNIQUEMENT les enfants qui ont le mode de garde "Nounou"
const nounouKids = (window.FAMILY_CONFIG.kids || []).filter(
(k) => k.modes && k.modes.some((m) => m.toLowerCase() === "nounou"),
);
// S'il n'y a pas d'enfant avec ce mode, on ne retourne rien pour ce bloc
if (nounouKids.length === 0) return "";
const daysInMonth = new Date(year, month + 1, 0).getDate(); const daysInMonth = new Date(year, month + 1, 0).getDate();
let html = "";
for (let d = 1; d <= daysInMonth; d++) { // 2. Boucler sur chaque enfant concerné pour un calcul individuel
const dateObj = new Date(year, month, d); nounouKids.forEach((kid) => {
const dayOfWeek = dateObj.getDay(); let workingDays = 0;
if (dayOfWeek === 0 || dayOfWeek === 6) continue; let off = 0;
let extra = 0;
let sick = 0;
const iso = this.getLocalIsoDate(dateObj); for (let d = 1; d <= daysInMonth; d++) {
const dayEvents = this.events.filter((e) => e.date === iso); const dateObj = new Date(year, month, d);
const dayOfWeek = dateObj.getDay();
dayEvents.forEach((e) => { // Exclure les week-ends des jours ouvrés (0 = Dimanche, 6 = Samedi)
const dur = parseFloat(e.duration) || 1; if (dayOfWeek === 0 || dayOfWeek === 6) continue;
if (e.type === "HELPER_OFF") stats.off += dur;
if (e.type === "HELPER_EXTRA") stats.extra += dur; const iso = this.getLocalIsoDate(dateObj);
if (e.type === "CHILD_SICK") stats.sick += dur;
}); // Exclure les jours fériés des jours ouvrés
if (this.publicHolidayDates.has(iso)) continue;
// C'est un jour ouvré valide
workingDays += 1;
const dayEvents = this.events.filter((e) => e.date === iso);
if (
!this.publicHolidayDates.has(iso) &&
!dayEvents.some((e) => e.type === "VACANCES_SCOLAIRES")
) {
let dayAbsence = 0;
dayEvents.forEach((e) => { dayEvents.forEach((e) => {
if (["HELPER_OFF", "HELPER_EXTRA", "CHILD_SICK"].includes(e.type)) { const dur = parseFloat(e.duration) || 1;
dayAbsence += parseFloat(e.duration) || 1;
if (e.type === "HELPER_OFF") off += dur;
if (e.type === "HELPER_EXTRA") extra += dur;
// On vérifie que la maladie concerne BIEN cet enfant précis !
if (
e.type === "CHILD_SICK" &&
Number(e.person_id) === Number(kid.id)
) {
sick += dur;
} }
}); });
stats.presence += Math.max(0, 1 - dayAbsence);
} }
}
return ` // Calcul final strict : Jours ouvrés - Maladie (de l'enfant) - Extra - Off
<div class="fc-month-summary-inline" style="display:flex; justify-content:space-around; gap:10px; margin-top:8px; font-size:0.75rem; background: var(--bg-panel); padding: 8px; border-radius: 8px; border: 1px solid var(--border-light);"> const presence = Math.max(0, workingDays - sick - extra - off);
<div class="fc-summ-pill" style="display:flex; flex-direction:column; align-items:center; flex:1;"><span>Off</span> <strong style="color:var(--text-main); font-size:0.95rem;">${parseFloat(stats.off.toFixed(1))} j</strong></div>
<div class="fc-summ-pill" style="display:flex; flex-direction:column; align-items:center; flex:1;"><span>Extra</span> <strong style="color:var(--text-main); font-size:0.95rem;">${parseFloat(stats.extra.toFixed(1))} j</strong></div> // Rendu HTML individuel
<div class="fc-summ-pill" style="display:flex; flex-direction:column; align-items:center; flex:1;"><span>Maladie</span> <strong style="color:var(--text-main); font-size:0.95rem;">${parseFloat(stats.sick.toFixed(1))} j</strong></div> html += `
<div class="fc-summ-pill" style="display:flex; flex-direction:column; align-items:center; flex:1;"><span>Présence</span> <strong style="color:var(--primary); font-size:0.95rem;">${parseFloat(stats.presence.toFixed(1))} j</strong></div> <div style="margin-top: 10px;">
<div style="font-size: 0.8rem; font-weight: bold; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em;">
👧👦 Présence ${kid.name} (Nounou)
</div>
<div class="fc-month-summary-inline" style="display:flex; justify-content:space-around; gap:10px; font-size:0.75rem; background: var(--bg-panel); padding: 8px; border-radius: 8px; border: 1px solid var(--border-light);">
<div class="fc-summ-pill" style="display:flex; flex-direction:column; align-items:center; flex:1;"><span>Ouvrés</span> <strong style="color:var(--text-main); font-size:0.95rem;">${workingDays} j</strong></div>
<div class="fc-summ-pill" style="display:flex; flex-direction:column; align-items:center; flex:1;"><span>Off</span> <strong style="color:var(--text-main); font-size:0.95rem;">${parseFloat(off.toFixed(1))} j</strong></div>
<div class="fc-summ-pill" style="display:flex; flex-direction:column; align-items:center; flex:1;"><span>Extra</span> <strong style="color:var(--text-main); font-size:0.95rem;">${parseFloat(extra.toFixed(1))} j</strong></div>
<div class="fc-summ-pill" style="display:flex; flex-direction:column; align-items:center; flex:1;"><span>Maladie</span> <strong style="color:var(--danger); font-size:0.95rem;">${parseFloat(sick.toFixed(1))} j</strong></div>
<div class="fc-summ-pill" style="display:flex; flex-direction:column; align-items:center; flex:1;"><span>Présence</span> <strong style="color:var(--primary); font-size:0.95rem;">${parseFloat(presence.toFixed(1))} j</strong></div>
</div>
</div> </div>
`; `;
});
return html;
} }
scrollToCurrentMonth() { scrollToCurrentMonth() {
+1003 -1089
View File
File diff suppressed because it is too large Load Diff
+1256 -324
View File
File diff suppressed because it is too large Load Diff
@@ -7,11 +7,20 @@ require_login();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// INTERCEPTIONS AJAX (Exécution ultra rapide sans rechargement lourd) // INTERCEPTIONS AJAX (Exécution ultra rapide sans rechargement lourd)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration', 'add_single_item'])) { if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration', 'add_single_item', 'update_holiday_note'])) {
header('Content-Type: application/json'); header('Content-Type: application/json');
session_write_close(); // 🚀 LIBÈRE LA SESSION : Permet d'autres requêtes simultanées sans bloquer le navigateur session_write_close();
try { try {
if ($_POST['action'] === 'update_holiday_note') {
$hId = (int)$_POST['holiday_id'];
$notes = $_POST['notes'] ?? '';
$stmt = $pdo->prepare("UPDATE pf_holidays SET notes = ? WHERE id = ?");
$stmt->execute([$notes, $hId]);
echo json_encode(['success' => true]);
exit;
}
if ($_POST['action'] === 'add_single_item') { if ($_POST['action'] === 'add_single_item') {
$holiday_id = (int)$_POST['holiday_id']; $holiday_id = (int)$_POST['holiday_id'];
$sort_order = (int)$_POST['sort_order']; $sort_order = (int)$_POST['sort_order'];
@@ -25,16 +34,43 @@ if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime
$date = !empty($_POST['item_date']) ? $_POST['item_date'] : null; $date = !empty($_POST['item_date']) ? $_POST['item_date'] : null;
$time = !empty($_POST['item_time']) ? $_POST['item_time'] : null; $time = !empty($_POST['item_time']) ? $_POST['item_time'] : null;
// Récupération sécurisée du contexte
$context = !empty($_POST['expense_context']) ? $_POST['expense_context'] : (!empty($_POST['context']) ? $_POST['context'] : 'local');
$amount = (float)$_POST['amount'];
$name = $_POST['name'] ?? 'Trajet';
$category = $_POST['category'] ?? 'transport';
$pdo->beginTransaction(); $pdo->beginTransaction();
// 🛡️ ANTI-DOUBLON ABSOLU
if ($context === 'transit') {
$stmtCheck = $pdo->prepare("SELECT id FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ? AND expense_context = 'transit'");
$stmtCheck->execute([$holiday_id, $sort_order]);
$existingId = $stmtCheck->fetchColumn();
if ($existingId) {
// Mise à jour de la ligne existante
$stmtUp = $pdo->prepare("UPDATE pf_holidays_items SET item_date = ?, item_time = ?, amount = ?, duration = ?, name = ? WHERE id = ?");
$stmtUp->execute([$date, $time, $amount, $dur, $name, $existingId]);
$pdo->commit();
echo json_encode(['success' => true, 'id' => $existingId]);
exit;
}
}
// Insertion si aucun doublon n'existe
$ins = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng, sort_order, step_start_date, step_end_date, step_type, expense_context, duration, item_date, item_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); $ins = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng, sort_order, step_start_date, step_end_date, step_type, expense_context, duration, item_date, item_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$ins->execute([ $ins->execute([
$holiday_id, $_POST['category'], $_POST['name'], (float)$_POST['amount'], 0, $holiday_id, $category, $name, $amount, 0,
$stepInfo['location_name'], $stepInfo['lat'], $stepInfo['lng'], $stepInfo['location_name'], $stepInfo['lat'], $stepInfo['lng'],
$sort_order, $stepInfo['step_start_date'], $stepInfo['step_end_date'], $sort_order, $stepInfo['step_start_date'], $stepInfo['step_end_date'],
$stepInfo['step_type'], $_POST['context'], $dur, $date, $time $stepInfo['step_type'], $context, $dur, $date, $time
]); ]);
$newId = $pdo->lastInsertId();
$pdo->commit(); $pdo->commit();
echo json_encode(['success' => true]);
echo json_encode(['success' => true, 'id' => $newId]);
exit; exit;
} }
echo json_encode(['success' => false, 'error' => 'Etape introuvable']); echo json_encode(['success' => false, 'error' => 'Etape introuvable']);
@@ -61,6 +97,7 @@ if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
exit; exit;
} }
} catch (Exception $e) { } catch (Exception $e) {
if ($pdo->inTransaction()) { $pdo->rollBack(); } if ($pdo->inTransaction()) { $pdo->rollBack(); }
echo json_encode(['success' => false, 'error' => $e->getMessage()]); echo json_encode(['success' => false, 'error' => $e->getMessage()]);
@@ -92,10 +129,19 @@ if ($holiday_id > 0 && !empty($location_name)) {
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]); $pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]);
$target_order = $old_sort_order; $target_order = $old_sort_order;
} else { } else {
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?"); // NOUVEAU : Logique d'intercalage d'étape
$stmtMax->execute([$holiday_id]); $insert_after = $_POST['insert_after'] ?? 'end';
$max = $stmtMax->fetchColumn();
$target_order = ($max !== null) ? (int)$max + 1 : 0; if ($insert_after === 'end') {
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
$stmtMax->execute([$holiday_id]);
$max = $stmtMax->fetchColumn();
$target_order = ($max !== null) ? (int)$max + 1 : 0;
} else {
$target_order = (int)$insert_after + 1;
// On décale toutes les étapes suivantes vers le bas
$pdo->prepare("UPDATE pf_holidays_items SET sort_order = sort_order + 1 WHERE holiday_id = ? AND sort_order >= ?")->execute([$holiday_id, $target_order]);
}
} }
$step_start = !empty($_POST['step_start_date']) ? $_POST['step_start_date'] : null; $step_start = !empty($_POST['step_start_date']) ? $_POST['step_start_date'] : null;
@@ -124,7 +170,11 @@ if ($holiday_id > 0 && !empty($location_name)) {
$dur = !empty($_POST['items']['duration'][$i]) ? (int)$_POST['items']['duration'][$i] : 1; $dur = !empty($_POST['items']['duration'][$i]) ? (int)$_POST['items']['duration'][$i] : 1;
$context = !empty($_POST['items']['context'][$i]) ? $_POST['items']['context'][$i] : 'local'; $context = !empty($_POST['items']['context'][$i]) ? $_POST['items']['context'][$i] : 'local';
$stmt->execute([$holiday_id, $cat, $name, $amount, $paid, $location_name, $lat, $lng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $step_type, $context]); // Récupération des coordonnées spécifiques à l'activité (ou fallback sur l'étape)
$itemLat = !empty($_POST['items']['lat'][$i]) ? (float)$_POST['items']['lat'][$i] : $lat;
$itemLng = !empty($_POST['items']['lng'][$i]) ? (float)$_POST['items']['lng'][$i] : $lng;
$stmt->execute([$holiday_id, $cat, $name, $amount, $paid, $location_name, $itemLat, $itemLng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $step_type, $context]);
$validItemsCount++; $validItemsCount++;
} }
} }
+41 -6
View File
@@ -28,16 +28,51 @@ $vehicle_id = !empty($_POST['vehicle_id']) ? (int)$_POST['vehicle_id'] : null;
try { try {
$pdo->beginTransaction(); $pdo->beginTransaction();
// 1. Détection d'un changement de nom pour refaire l'appel API
$image_url = null;
$old_title = null;
if ($id) { if ($id) {
// UPDATE (avec vehicle_id) $stmtOld = $pdo->prepare("SELECT title, image_url FROM pf_holidays WHERE id = ?");
$sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=?, vehicle_id=? WHERE id=?"; $stmtOld->execute([$id]);
if ($row = $stmtOld->fetch()) {
$old_title = $row['title'];
$image_url = $row['image_url'];
}
}
// 2. Appel à Pixabay si nouveau voyage ou si le titre a changé
if (!$id || strcasecmp($old_title ?? '', $title) !== 0) {
$apiKey = '56931941-a86fbea4e14b88712cc1e9ed9';
$query = urlencode($title);
// On force des photos horizontales HD
$url = "https://pixabay.com/api/?key={$apiKey}&q={$query}&image_type=photo&orientation=horizontal&min_width=1280&per_page=3";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 3); // Timeout de 3s max pour ne pas bloquer l'UI
$response = curl_exec($ch);
curl_close($ch);
if ($response) {
$data = json_decode($response, true);
if (!empty($data['hits'][0]['largeImageURL'])) {
$image_url = $data['hits'][0]['largeImageURL'];
}
}
}
// 3. Sauvegarde en Base
if ($id) {
// UPDATE
$sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=?, vehicle_id=?, image_url=? WHERE id=?";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $id]); $stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $image_url, $id]);
} else { } else {
// INSERT (avec vehicle_id) // INSERT
$sql = "INSERT INTO pf_holidays (title, period_hint, start_date, end_date, status, budget_food, budget_extra, notes, vehicle_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; $sql = "INSERT INTO pf_holidays (title, period_hint, start_date, end_date, status, budget_food, budget_extra, notes, vehicle_id, image_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id]); $stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $image_url]);
$id = $pdo->lastInsertId(); $id = $pdo->lastInsertId();
} }
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -122,9 +122,10 @@ window.closeHolidayModal = window.closeHolidayModal || function() {
if(modal) modal.style.display = 'none'; if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll'); document.body.classList.remove('no-scroll');
}; };
</script> </script>
<script src="/modules/holidays/holidays.js"></script>
<?php <?php
function renderHolidayCard($h, $pdo) { function renderHolidayCard($h, $pdo) {
@@ -136,8 +137,9 @@ function renderHolidayCard($h, $pdo) {
$dateDisplay = htmlspecialchars($h['period_hint'] ?? ''); $dateDisplay = htmlspecialchars($h['period_hint'] ?? '');
if (empty($dateDisplay) && $h['start_date']) { if (empty($dateDisplay) && $h['start_date']) {
$dateDisplay = date('d/m/Y', strtotime($h['start_date'])); // 💡 Format Jour/Mois uniquement
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($h['end_date'])); $dateDisplay = date('d/m', strtotime($h['start_date']));
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m', strtotime($h['end_date']));
} }
$statusClass = match($h['status']) { $statusClass = match($h['status']) {
+13 -19
View File
@@ -1,5 +1,6 @@
<div id="holidayModal" class="pf-modal"> <div id="holidayModal" class="pf-modal">
<div class="pf-modal-content hol-modal-content" style="max-width: 500px;"> <h3 id="modalTitle" class="pf-modal-title"><?= tr('hdl_modal_title') ?></h3> <div class="pf-modal-content hol-modal-content" style="max-width: 500px;">
<h3 id="modalTitle" class="pf-modal-title"><?= tr('hdl_modal_title') ?></h3>
<form action="/modules/holidays/includes/api/save_holiday.php" method="POST" id="holidayForm"> <form action="/modules/holidays/includes/api/save_holiday.php" method="POST" id="holidayForm">
<input type="hidden" name="id" id="inp_id"> <input type="hidden" name="id" id="inp_id">
@@ -23,23 +24,21 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<div> <div class="hol-flex-1">
<label class="pf-label"><?= tr('hdl_label_period') ?></label> <label class="pf-label"><?= tr('hdl_label_period') ?></label>
<input type="text" name="period_hint" id="inp_period" class="pf-input" placeholder="<?= tr('hdl_ph_period') ?>"> <input type="text" name="period_hint" id="inp_period" class="pf-input" placeholder="<?= tr('hdl_ph_period') ?>">
</div> </div>
<div class="hol-date-range-group"> <!-- DATES AVEC FLATPICKR -->
<div class="hol-flex-1"> <div class="hol-flex-2">
<label class="pf-label"><?= tr('hdl_label_from') ?></label> <label class="pf-label">📅 Dates du voyage</label>
<input type="date" name="start_date" id="inp_start" class="pf-input"> <input type="text" id="hol_date_range" class="pf-input" placeholder="Sélectionnez les dates..." readonly style="cursor: pointer; background-color: var(--bg-panel); color: var(--primary); font-weight: bold; text-align: center; letter-spacing: 0.5px;">
</div> <!-- Vraies données pour save_holiday.php -->
<div class="hol-flex-1"> <input type="hidden" name="start_date" id="inp_start">
<label class="pf-label"><?= tr('hdl_label_to') ?></label> <input type="hidden" name="end_date" id="inp_end">
<input type="date" name="end_date" id="inp_end" class="pf-input">
</div>
</div> </div>
</div> </div>
<div class="form-group" style="margin-top: 15px; padding: 10px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;"> <div class="form-group" style="margin-top: 15px; padding: 10px; background: var(--bg-subtle); border-radius: 8px; border: 1px solid var(--border-light);">
<label class="pf-label" style="margin-bottom: 5px;">🚗 Véhicule utilisé (Optionnel)</label> <label class="pf-label" style="margin-bottom: 5px;">🚗 Véhicule utilisé (Optionnel)</label>
<select name="vehicle_id" id="inp_vehicle_id" class="pf-input"> <select name="vehicle_id" id="inp_vehicle_id" class="pf-input">
<option value="">-- Aucun / Autre transport --</option> <option value="">-- Aucun / Autre transport --</option>
@@ -52,21 +51,16 @@
<hr class="hol-divider"> <hr class="hol-divider">
<div class="form-row"> <div class="form-row">
<div> <div class="hol-flex-1">
<label class="pf-label">🍔 <?= tr('hdl_label_budget_food') ?></label> <label class="pf-label">🍔 <?= tr('hdl_label_budget_food') ?></label>
<input type="number" step="0.01" name="budget_food" id="inp_food" class="pf-input" placeholder="0.00"> <input type="number" step="0.01" name="budget_food" id="inp_food" class="pf-input" placeholder="0.00">
</div> </div>
<div> <div class="hol-flex-1">
<label class="pf-label">🎁 <?= tr('hdl_label_budget_extras') ?></label> <label class="pf-label">🎁 <?= tr('hdl_label_budget_extras') ?></label>
<input type="number" step="0.01" name="budget_extra" id="inp_extra" class="pf-input" placeholder="0.00"> <input type="number" step="0.01" name="budget_extra" id="inp_extra" class="pf-input" placeholder="0.00">
</div> </div>
</div> </div>
<div class="form-group" style="margin-top: 10px;">
<label class="pf-label"><?= tr('hdl_label_notes') ?></label>
<textarea name="notes" id="inp_notes" class="pf-input" rows="2" placeholder="<?= tr('hdl_ph_notes') ?>"></textarea>
</div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" onclick="deleteHoliday()" id="btn_delete" class="pf-btn btn-secondary hol-btn-delete"><?= tr('btn_delete') ?></button> <button type="button" onclick="deleteHoliday()" id="btn_delete" class="pf-btn btn-secondary hol-btn-delete"><?= tr('btn_delete') ?></button>
<button type="button" onclick="closeHolidayModal()" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button> <button type="button" onclick="closeHolidayModal()" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
+1 -1
View File
@@ -19,7 +19,7 @@ $grandTotal = $totalGeneral + $totalSteps;
<h1 style="font-size: 2.5rem; color: #0f172a; margin-bottom: 10px;"><?= htmlspecialchars($holiday['title'] ?? $holiday['name'] ?? 'Mon Voyage') ?></h1> <h1 style="font-size: 2.5rem; color: #0f172a; margin-bottom: 10px;"><?= htmlspecialchars($holiday['title'] ?? $holiday['name'] ?? 'Mon Voyage') ?></h1>
<h2 style="font-size: 1.5rem; color: #64748b; font-weight: normal; margin-top: 0;"> <h2 style="font-size: 1.5rem; color: #64748b; font-weight: normal; margin-top: 0;">
<?php if (!empty($holiday['start_date']) && !empty($holiday['end_date'])): ?> <?php if (!empty($holiday['start_date']) && !empty($holiday['end_date'])): ?>
Du <?= date('d/m/Y', strtotime($holiday['start_date'])) ?> au <?= date('d/m/Y', strtotime($holiday['end_date'])) ?> Du <?= date('d/m', strtotime($holiday['start_date'])) ?> au <?= date('d/m', strtotime($holiday['end_date'])) ?>
<?php else: ?> <?php else: ?>
Dates à définir Dates à définir
<?php endif; ?> <?php endif; ?>
+1 -1
View File
@@ -153,7 +153,7 @@ function renderAttachmentsView(atts,noteId){
<div class="memo-att-title">🖼️ Images</div> <div class="memo-att-title">🖼️ Images</div>
<div class="att-img-grid">${images.map(a=>` <div class="att-img-grid">${images.map(a=>`
<div class="att-img-item" onclick="openLightbox('${API}?action=file&id=${a.id}')"> <div class="att-img-item" onclick="openLightbox('${API}?action=file&id=${a.id}')">
<img src="${API}?action=file&id=${a.id}" loading="lazy" alt="${escHtml(a.original_name||'')}"> <img src="${API}?action=file&id=${a.id}" alt="${escHtml(a.original_name||'')}">
<button class="att-del" onclick="event.stopPropagation();deleteAttachment(${a.id},${noteId})" title="Supprimer">✕</button> <button class="att-del" onclick="event.stopPropagation();deleteAttachment(${a.id},${noteId})" title="Supprimer">✕</button>
</div>`).join('')}</div></div>`; </div>`).join('')}</div></div>`;
} }
+6 -16
View File
@@ -26,33 +26,23 @@ $stops = $params['stops'];
$fuelL100 = (float)($params['fuel_l100'] ?? 7); $fuelL100 = (float)($params['fuel_l100'] ?? 7);
$fuelPrice= (float)($params['fuel_price'] ?? 1.85); $fuelPrice= (float)($params['fuel_price'] ?? 1.85);
// --- Load databases --- // --- Load pre-indexed PHP database ---
$tollsPath = __DIR__ . '/data/tolls.json'; $tollsDataPath = __DIR__ . '/data/tolls.data.php';
$gpsPath = __DIR__ . '/data/toll_gps.json'; $gpsPath = __DIR__ . '/data/toll_gps.json';
if (!file_exists($tollsPath)) { if (!file_exists($tollsDataPath)) {
echo json_encode(['ok' => false, 'error' => 'Base de péages introuvable']); echo json_encode(['ok' => false, 'error' => 'Base de péages introuvable']);
exit; exit;
} }
$tollsRaw = json_decode(file_get_contents($tollsPath), true); // Grâce à OPcache, le fichier est exécuté et stocké en RAM partagée
$tollData = $tollsRaw['data'] ?? []; $tollIndex = require $tollsDataPath;
$gpsData = []; $gpsData = [];
if (file_exists($gpsPath)) { if (file_exists($gpsPath)) {
$gpsData = json_decode(file_get_contents($gpsPath), true) ?? []; $gpsData = json_decode(file_get_contents($gpsPath), true) ?? [];
} }
// --- Build lookup index: [op][entry][exit] => c1_price ---
$tollIndex = [];
foreach ($tollData as $row) {
$op = $row['op'];
$e = $row['e'];
$x = $row['x'];
$c1 = (float)$row['c1'];
$tollIndex[$op][$e][$x] = $c1;
}
// --- Helper: great-circle distance (km) --- // --- Helper: great-circle distance (km) ---
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float { function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
$R = 6371.0; $R = 6371.0;
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long