query("SELECT content FROM pf_notes WHERE note_type = 'active_gestion_month' LIMIT 1"); $defaultActiveMonth = $stmtActive->fetchColumn(); if (!$defaultActiveMonth) { $defaultActiveMonth = date('Y-m-01'); } $viewM = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m', strtotime($defaultActiveMonth)); $viewY = isset($_GET['y']) ? (int)$_GET['y'] : date('Y', strtotime($defaultActiveMonth)); $viewMonthDate = "$viewY-$viewM-01"; $prevDate = date('Y-m-01', strtotime('-1 month', strtotime($viewMonthDate))); $nextDate = date('Y-m-01', strtotime('+1 month', strtotime($viewMonthDate))); $prevLink = "?tab=suivi&m=" . date('m', strtotime($prevDate)) . "&y=" . date('Y', strtotime($prevDate)); $nextLink = "?tab=suivi&m=" . date('m', strtotime($nextDate)) . "&y=" . date('Y', strtotime($nextDate)); $defaultLink = "?tab=suivi"; // ============================================================================ // 2. GESTION DES ACTIONS POST (Clôture, Ajouts, CSV) // ============================================================================ if (isset($_POST['action']) && $_POST['action'] === 'close_month') { $monthToClose = $_POST['close_month_date']; $nextMonthToOpen = date('Y-m-01', strtotime('+1 month', strtotime($monthToClose))); $frozenData = [ 'is_closed' => true, 'solde_actuel' => (float)$_POST['freeze_solde_actuel'], 'capacite_max' => (float)$_POST['freeze_capacite_max'], 'solde_theorique' => (float)$_POST['freeze_solde_theorique'], 'reste_a_venir' => (float)$_POST['freeze_reste_a_venir'], 'closed_at' => date('Y-m-d H:i:s') ]; $pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('month_closure', ?, ?) ON DUPLICATE KEY UPDATE content = VALUES(content)") ->execute([$monthToClose, json_encode($frozenData)]); $pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('active_gestion_month', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)") ->execute([$nextMonthToOpen]); echo ""; exit; } if (isset($_POST['action']) && $_POST['action'] === 'reopen_month') { $pdo->prepare("DELETE FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?")->execute([$viewMonthDate]); $pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('active_gestion_month', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)") ->execute([$viewMonthDate]); echo ""; exit; } if (isset($_POST['action']) && $_POST['action'] === 'save_import') { $count = 0; $stmtExp = $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id, salary_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); $stmtRule = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category, budget_item_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE category = VALUES(category), budget_item_id = VALUES(budget_item_id)"); if (isset($_POST['lines']) && is_array($_POST['lines'])) { foreach ($_POST['lines'] as $line) { if (isset($line['import_check'])) { $cat = $line['cat']; $is_credit = isset($line['is_credit']) ? (int)$line['is_credit'] : 0; // 🔥 NOUVEAU TRAITEMENT DU BUDGET_ITEM_ID $rawItemId = $line['budget_item_id'] ?? ''; $budgetItemId = null; $salaryId = null; if (!empty($rawItemId)) { if (strpos($rawItemId, 'SAL_') === 0) { $salaryId = (int)str_replace('SAL_', '', $rawItemId); } else { $budgetItemId = (int)$rawItemId; } } $holidayId = !empty($line['holiday_id']) ? (int)$line['holiday_id'] : null; $gestionMonthLine = !empty($line['gestion_month']) ? $line['gestion_month'] . '-01' : $viewMonthDate; if ($is_credit && empty($cat)) continue; if (!$is_credit && empty($cat)) continue; $finalAmount = $is_credit ? abs($line['amount']) : -abs($line['amount']); $dateToSave = $line['date']; try { $stmtExp->execute([$dateToSave, $gestionMonthLine, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId, $salaryId]); $stmtRule->execute([$line['label'], $cat, $budgetItemId]); $count++; } catch (Exception $e) { continue; } } } } echo ""; exit; } if (isset($_POST['action']) && $_POST['action'] === 'save_snapshot') { $pdo->query("DELETE FROM pf_bank_snapshots"); $pdo->prepare("INSERT INTO pf_bank_snapshots (snapshot_date, amount) VALUES (?, ?)")->execute([$_POST['snapshot_date'], floatval($_POST['snapshot_amount'])]); echo ""; exit; } // ============================================================================ // E-Bis. LECTURE ET PRÉVISUALISATION DU FICHIER CSV // ============================================================================ $csvData = []; $showPreview = false; if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) { $file = $_FILES['csv_file']['tmp_name']; $handle = fopen($file, "r"); $rules = []; try { $stmtRules = $pdo->query("SELECT keyword, category, budget_item_id FROM pf_import_rules"); while ($r = $stmtRules->fetch(PDO::FETCH_ASSOC)) { $rules[$r['keyword']] = ['cat' => $r['category'], 'budget_item_id' => $r['budget_item_id']]; } } catch(Exception $e){} $existingRefs = []; try { $existingRefs = $pdo->query("SELECT import_ref FROM pf_expenses WHERE import_ref IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN); } catch(Exception $e){} fgetcsv($handle, 1000, ";", "\"", "\\"); while (($data = fgetcsv($handle, 1000, ";", "\"", "\\")) !== FALSE) { $rawDebit = $data[8] ?? ''; $rawCredit = $data[9] ?? ''; $amount = 0; $isCredit = 0; if (!empty(trim($rawCredit))) { $amount = abs((float)str_replace(',', '.', str_replace(' ', '', $rawCredit))); $isCredit = 1; } elseif (!empty(trim($rawDebit))) { $amount = abs((float)str_replace(',', '.', str_replace(' ', '', $rawDebit))); } else continue; $dateParts = explode('/', $data[0]); $dateSql = (count($dateParts) == 3) ? $dateParts[2].'-'.$dateParts[1].'-'.$dateParts[0] : date('Y-m-d'); $label = trim($data[1]) ?: trim($data[2]); $refCSV = trim($data[3]); $uniqueKey = !empty($refCSV) ? "REF_".$refCSV : "HASH_".md5($dateSql.$label.number_format($amount, 2).$isCredit); $isDuplicate = in_array($uniqueKey, $existingRefs); $suggestedCat = ''; $suggestedItemId = null; foreach ($rules as $kw => $ruleData) { if (stripos($label, $kw) !== false) { $suggestedCat = $ruleData['cat']; $suggestedItemId = $ruleData['budget_item_id']; break; } } $csvData[] = ['date'=>$dateSql, 'label'=>$label, 'amount'=>$amount, 'cat'=>$suggestedCat, 'suggested_item_id'=>$suggestedItemId, 'ref'=>$uniqueKey, 'is_duplicate'=>$isDuplicate, 'is_credit'=>$isCredit]; } fclose($handle); $showPreview = true; } // ============================================================================ // 3. RECUPERATION DES DONNEES ET CALCULS (100% DYNAMIQUE) // ============================================================================ // --- LECTURE DYNAMIQUE DES CATEGORIES --- $stmtCats = $pdo->query("SELECT * FROM pf_budget_categories ORDER BY type DESC, label ASC"); $dbCategories = $stmtCats->fetchAll(PDO::FETCH_ASSOC); $categoriesConfig = []; foreach ($dbCategories as $c) { $catType = ($c['type'] === 'Income') ? 'credit' : 'debit'; $categoriesConfig[$c['code']] = [ 'type' => $catType, 'db_type' => $c['type'], 'label' => ($c['icon'] ? $c['icon'] . ' ' : '') . $c['label'], 'budget' => 0, // Sera rempli par les règles 'color' => $c['color'] ?: '#64748b', 'suggestions' => [] ]; } // Fallback "Autres" au cas où la BDD serait vide ou pour les dépenses non classées if (!isset($categoriesConfig['AUTRES'])) { $categoriesConfig['AUTRES'] = [ 'type'=>'debit', 'db_type'=>'Expense', 'label'=>'📁 Autres / Divers', 'budget'=>0, 'color'=>'#94a3b8', 'suggestions'=>[] ]; } // Peuplement dynamique des suggestions via les règles existantes $stmtRules = $pdo->query("SELECT keyword, category FROM pf_import_rules"); while ($rule = $stmtRules->fetch(PDO::FETCH_ASSOC)) { if (isset($categoriesConfig[$rule['category']])) { $categoriesConfig[$rule['category']]['suggestions'][] = $rule['keyword']; } } $stmtCheckClose = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?"); $stmtCheckClose->execute([$viewMonthDate]); $closureJson = $stmtCheckClose->fetchColumn(); $monthState = $closureJson ? json_decode($closureJson, true) : null; $isClosed = ($monthState && isset($monthState['is_closed']) && $monthState['is_closed'] === true); $snapshot = ['date' => date('Y-m-d'), 'amount' => 0]; $snapStmt = $pdo->query("SELECT * FROM pf_bank_snapshots ORDER BY id DESC LIMIT 1"); if ($s = $snapStmt->fetch(PDO::FETCH_ASSOC)) { $snapshot = ['date' => $s['snapshot_date'], 'amount' => (float)$s['amount']]; } $stmtPrevClose = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?"); $stmtPrevClose->execute([$prevDate]); $prevClosureJson = $stmtPrevClose->fetchColumn(); $prevMonthState = $prevClosureJson ? json_decode($prevClosureJson, true) : null; $solde_initial = ($prevMonthState && isset($prevMonthState['solde_actuel'])) ? (float)$prevMonthState['solde_actuel'] : 0; if ($solde_initial === 0 && !$prevMonthState) { $solde_initial = $snapshot['amount']; } $stmt = $pdo->prepare("SELECT * FROM pf_expenses WHERE gestion_month = ? ORDER BY date_exp DESC"); $stmt->execute([$viewMonthDate]); $allExpenses = $stmt->fetchAll(PDO::FETCH_ASSOC); $paidItemIds = array_column(array_filter($allExpenses, fn($e) => !empty($e['budget_item_id'])), 'budget_item_id'); $realExpensesLabels = array_column(array_filter($allExpenses, fn($e) => $e['amount'] < 0), 'label'); $budget_income_prevu = 0; $total_income = 0; $total_expenses_prevues = 0; $reste_a_venir_calc = 0; $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"); $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']); $amt = ($item['type'] === 'Annuel') ? $absAmount / 12 : $absAmount; $name = trim($item['name']); $catCode = $item['category']; $isIncome = (strtoupper($catCode) === 'INCOME'); if ($isIncome) { $incomeList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount]; $total_income += $amt; $budget_income_prevu += $amt; if (!empty($catCode) && isset($categoriesConfig[$catCode])) { $categoriesConfig[$catCode]['budget'] += $amt; } else { $incomeCatKey = array_key_first(array_filter($categoriesConfig, fn($c) => $c['db_type'] === 'Income')); if ($incomeCatKey) $categoriesConfig[$incomeCatKey]['budget'] += $amt; } } else { $total_expenses_prevues += $amt; // 1. C'est une charge fixe (Non-variable) if ($item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) { $fixedChargesList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount]; $isPaid = false; if (in_array($item['id'], $paidItemIds)) $isPaid = true; elseif (!empty($item['mapping_keywords'])) { $keywords = array_map('trim', explode(',', $item['mapping_keywords'])); foreach ($keywords as $kw) { if (empty($kw)) continue; foreach ($realExpensesLabels as $realLabel) { if (stripos($realLabel, $kw) !== false) { $isPaid = true; break 2; } } } } 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']; } } // 2. C'est une estimation (Variable) if ((int)$item['is_estimate'] === 1) { $estimatesList[] = [ 'name' => $name, 'amount' => $amt, 'categories' => !empty($catCode) ? array_map('trim', explode(',', $catCode)) : [] ]; } // 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]; if (isset($categoriesConfig[$firstCat])) { $categoriesConfig[$firstCat]['budget'] += $amt; } } } } $stmtSalaries = $pdo->query("SELECT id, person, salary FROM pf_salary_config WHERE year = " . $viewY); while ($sal = $stmtSalaries->fetch(PDO::FETCH_ASSOC)) { $incomeList[] = [ 'id' => 'SAL_' . $sal['id'], 'name' => 'Salaire ' . $sal['person'], 'amount' => (float)$sal['salary'] ]; } // L'enveloppe "Autres" prend tout le reste du budget non alloué $categoriesConfig['AUTRES']['budget'] = max(0, $total_income - $total_expenses_prevues); $totals = array_fill_keys(array_keys($categoriesConfig), 0); $expensesByCategory = array_fill_keys(array_keys($categoriesConfig), []); $total_rentrees = 0; $depenses_reelles = 0; // VENTILATION DYNAMIQUE DES DÉPENSES foreach ($allExpenses as $exp) { $cat = $exp['category']; if (!isset($categoriesConfig[$cat])) $cat = 'AUTRES'; $val = (float)$exp['amount']; if ($categoriesConfig[$cat]['db_type'] === 'Income') { $totals[$cat] += $val; } else { if ($val > 0) $categoriesConfig[$cat]['budget'] += $val; // Remboursement else $totals[$cat] += abs($val); } $expensesByCategory[$cat][] = $exp; if ($val > 0) { if ($categoriesConfig[$cat]['db_type'] === 'Income') { $total_rentrees += $val; } else { $depenses_reelles -= $val; } } else { $depenses_reelles += abs($val); } } // CALCUL DES RESTES À VENIR BASÉ SUR LES ESTIMATIONS (MACRO) 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) . "€"; } } } $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'; $pending_charges[] = [ 'name' => 'Reste ' . $est['name'], 'amount' => $rem, 'tooltip' => $tooltip ]; } } // F. Calculs des KPIs finaux $rentrees_salaires_reels = 0; foreach($categoriesConfig as $code => $conf) { if($conf['db_type'] === 'Income') $rentrees_salaires_reels += ($totals[$code] ?? 0); } $rentrees_autres = $total_rentrees - $rentrees_salaires_reels; $salaires_retenus = max($rentrees_salaires_reels, $budget_income_prevu); $capacite_max_calc = $solde_initial + $salaires_retenus + $rentrees_autres; $revenus_a_venir = max(0, $budget_income_prevu - $rentrees_salaires_reels); $solde_theorique_calc = $snapshot['amount'] + $revenus_a_venir - $reste_a_venir_calc; if ($isClosed) { $solde_actuel = $monthState['solde_actuel']; $capacite_max = $monthState['capacite_max']; $solde_theorique = $monthState['solde_theorique']; $reste_a_venir = $monthState['reste_a_venir']; } else { $solde_actuel = $snapshot['amount']; $capacite_max = $capacite_max_calc; $solde_theorique = $solde_theorique_calc; $reste_a_venir = $reste_a_venir_calc; } $solde_net = max(0, $solde_actuel - $reste_a_venir); $charges_visibles = min($solde_actuel, $reste_a_venir); $max_scale = max($solde_actuel, $solde_theorique, $capacite_max, 1) * 1.1; $pct_net = min(100, max(0, ($solde_net / $max_scale) * 100)); $pct_charges = min(100 - $pct_net, max(0, ($charges_visibles / $max_scale) * 100)); $pct_actuel = min(100, max(0, ($solde_actuel / $max_scale) * 100)); $pct_theorique = min(100, max(0, ($solde_theorique / $max_scale) * 100)); function getDisplayLogic($spent, $bg, $type) { $pct = ($bg > 0) ? min(100, ($spent / $bg) * 100) : ($spent > 0 ? 100 : 0); $isOver = ($type === 'debit' && $spent > $bg && $bg > 0); $text = ($bg > 0) ? number_format(ceil($spent), 0, ',', ' ') . ' / ' . number_format(ceil($bg), 0, ',', ' ') . ' €' : number_format(ceil($spent), 0, ',', ' ') . ' €'; return ['pct' => $pct, 'isOver' => $isOver, 'text' => $text]; } $monthNames = [ 1 => tr('month_01'), 2 => tr('month_02'), 3 => tr('month_03'), 4 => tr('month_04'), 5 => tr('month_05'), 6 => tr('month_06'), 7 => tr('month_07'), 8 => tr('month_08'), 9 => tr('month_09'), 10 => tr('month_10'), 11 => tr('month_11'), 12 => tr('month_12') ]; $monthName = $monthNames[(int)$viewM] . ' ' . $viewY; ?>
| = date('d/m', strtotime($exp['date_exp'])) ?> | = htmlspecialchars($exp['label']) ?> | = $exp['amount'] > 0 ? '+' : '-' ?>= number_format(abs($exp['amount']), 2) ?> |