12) { $nextM = 1; $nextY++; } $prevLink = "?tab=suivi&m=$prevM&y=$prevY"; $nextLink = "?tab=suivi&m=$nextM&y=$nextY"; $todayLink = "?tab=suivi"; // ============================================================================ // NOUVEAU : CONFIGURATION DU CYCLE (Dates personnalisées) // ============================================================================ // 1. On lit la configuration du mois en cours $stmtConfig = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_config' AND reference_id = ?"); $stmtConfig->execute([$currentMonthKey]); $configJson = $stmtConfig->fetchColumn(); $monthConfig = $configJson ? json_decode($configJson, true) : null; $customStartDate = $monthConfig['start_date'] ?? "$currentYear-$currentMonth-01"; $customStartBalance = $monthConfig['start_balance'] ?? 0; // 2. On lit la configuration du mois SUIVANT pour fermer le cycle proprement $nextMonthKey = str_pad($nextM, 2, '0', STR_PAD_LEFT) . '-' . $nextY; $stmtNextConfig = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_config' AND reference_id = ?"); $stmtNextConfig->execute([$nextMonthKey]); $nextConfigJson = $stmtNextConfig->fetchColumn(); $nextConfig = $nextConfigJson ? json_decode($nextConfigJson, true) : null; // La date de fin est soit la veille du prochain cycle, soit la fin du mois calendaire if ($nextConfig && !empty($nextConfig['start_date'])) { $customEndDate = date('Y-m-d', strtotime($nextConfig['start_date'] . ' -1 day')); } else { $customEndDate = date('Y-m-t', strtotime("$currentYear-$currentMonth-01")); } // A. AJOUT CATÉGORIE TEMPORAIRE MANUELLE if (isset($_POST['action']) && $_POST['action'] === 'add_temp_cat') { $name = trim($_POST['cat_name']); $budget = floatval($_POST['cat_budget']); $type = $_POST['cat_type'] === 'credit' ? 'credit' : 'debit'; if ($type === 'debit') $budget = -$budget; if ($name) { $stmt = $pdo->prepare("INSERT INTO pf_monthly_categories (month_year, name, type, budget) VALUES (?, ?, ?, ?)"); $stmt->execute([$currentMonthKey, $name, $type, $budget]); header("Location: ?tab=suivi&m=$currentMonth&y=$currentYear"); exit; } } // B. SUPPRESSION CATÉGORIE TEMPORAIRE if (isset($_GET['del_cat'])) { $pdo->prepare("DELETE FROM pf_monthly_categories WHERE id = ?")->execute([(int)$_GET['del_cat']]); header("Location: ?tab=suivi&m=$currentMonth&y=$currentYear"); exit; } // C. SAUVEGARDE SNAPSHOT BANCAIRE if (isset($_POST['action']) && $_POST['action'] === 'save_snapshot') { $date = $_POST['snapshot_date']; $amount = floatval($_POST['snapshot_amount']); $pdo->query("DELETE FROM pf_bank_snapshots"); $pdo->prepare("INSERT INTO pf_bank_snapshots (snapshot_date, amount) VALUES (?, ?)")->execute([$date, $amount]); header("Location: ?tab=suivi&m=$currentMonth&y=$currentYear"); exit; } // D. SAUVEGARDE IMPORT CSV if (isset($_POST['action']) && $_POST['action'] === 'save_import') { $count = 0; $viewMonth = $_POST['view_month'] ?? $currentMonth; $viewYear = $_POST['view_year'] ?? $currentYear; $tempCatMapping = []; if (!empty($_POST['new_temp_cats'])) { $stmtTemp = $pdo->prepare("INSERT INTO pf_monthly_categories (month_year, name, type, budget) VALUES (?, ?, ?, 0)"); foreach ($_POST['new_temp_cats'] as $tempKey => $catData) { $stmtTemp->execute([$currentMonthKey, $catData['name'], $catData['type']]); $tempCatMapping[$tempKey] = $pdo->lastInsertId(); } } $stmtExp = $pdo->prepare("INSERT INTO pf_expenses (date_exp, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?)"); $stmtRule = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category) VALUES (?, ?) ON DUPLICATE KEY UPDATE category = VALUES(category)"); 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; $budgetItemId = !empty($line['budget_item_id']) ? (int)$line['budget_item_id'] : null; $holidayId = !empty($line['holiday_id']) ? (int)$line['holiday_id'] : null; if ($is_credit && empty($cat)) continue; if (!$is_credit && empty($cat)) continue; if (strpos($cat, 'NEW_TEMP_') === 0 && isset($tempCatMapping[$cat])) { $cat = 'TEMP_' . $tempCatMapping[$cat]; } $finalAmount = $is_credit ? abs($line['amount']) : -abs($line['amount']); $dateToSave = $line['date']; if (!empty($line['force_current'])) { $day = date('d', strtotime($dateToSave)); if (checkdate((int)$viewMonth, (int)$day, (int)$viewYear)) { $dateToSave = "$viewYear-$viewMonth-$day"; } else { $dateToSave = date('Y-m-t', strtotime("$viewYear-$viewMonth-01")); } } try { $stmtExp->execute([$dateToSave, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId]); $stmtRule->execute([$line['label'], $cat]); $count++; } catch (Exception $e) { continue; } } } } header("Location: ?tab=suivi&m=$viewMonth&y=$viewYear&msg=imported_$count"); exit; } // E. AJOUT OU MODIFICATION DÉPENSE (MANUELLE) if (isset($_POST['action']) && $_POST['action'] === 'save_expense_manual') { $id = !empty($_POST['expense_id']) ? (int)$_POST['expense_id'] : null; $cat = $_POST['category']; $amount = floatval($_POST['amount']); $date = $_POST['date']; $label = trim($_POST['label']); $budgetItemId = null; $holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; if ($cat === 'School' && !empty($_POST['label_select'])) $label = trim($_POST['label_select']); elseif (($cat === 'Frais' || $cat === 'Income') && !empty($_POST['budget_item_id'])) $budgetItemId = (int)$_POST['budget_item_id']; if ($label && $amount > 0) { $is_credit = isset($_POST['is_credit']) ? (int)$_POST['is_credit'] : 0; $finalAmount = $is_credit ? abs($amount) : -abs($amount); if ($id) { $pdo->prepare("UPDATE pf_expenses SET date_exp=?, category=?, label=?, amount=?, budget_item_id=?, holiday_id=? WHERE id=?") ->execute([$date, $cat, $label, $finalAmount, $budgetItemId, $holidayId, $id]); } else { $uniqueRef = "MANUAL_" . uniqid(); $pdo->prepare("INSERT INTO pf_expenses (date_exp, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?)") ->execute([$date, $cat, $label, $finalAmount, $uniqueRef, $budgetItemId, $holidayId]); } header("Location: ?tab=suivi&m=$currentMonth&y=$currentYear"); exit; } } // F. SUPPRESSION DÉPENSE if (isset($_GET['delete_expense'])) { $pdo->prepare("DELETE FROM pf_expenses WHERE id = ?")->execute([(int)$_GET['delete_expense']]); header("Location: ?tab=suivi&m=$currentMonth&y=$currentYear"); exit; } // G. RECUPERATION VACANCES $activeHolidays = $pdo->query("SELECT id, title FROM pf_holidays WHERE status IN ('draft', 'planned', 'booked') ORDER BY start_date ASC")->fetchAll(PDO::FETCH_ASSOC); // ============================================================================ // 2. CALCUL DES BUDGETS & CHARGES FIXES // ============================================================================ $budget_fmcg = 0; $budget_school = 0; $budget_essence = 0; $budget_frais = 0; $budget_income_prevu = 0; $total_income = 0; $total_expenses_prevues = 0; $reste_a_venir = 0; $today_day = (int)date('j'); $fixedChargesList = []; $incomeList = []; // MODIFICATION : Les requêtes ciblent désormais les bornes du CYCLE personnalisé, plus le mois strict ! $stmtIds = $pdo->prepare("SELECT DISTINCT budget_item_id FROM pf_expenses WHERE date_exp >= ? AND date_exp <= ? AND budget_item_id IS NOT NULL"); $stmtIds->execute([$customStartDate, $customEndDate]); $paidItemIds = $stmtIds->fetchAll(PDO::FETCH_COLUMN); $stmtLabels = $pdo->prepare("SELECT label FROM pf_expenses WHERE date_exp >= ? AND date_exp <= ? AND amount < 0"); $stmtLabels->execute([$customStartDate, $customEndDate]); $realExpensesLabels = $stmtLabels->fetchAll(PDO::FETCH_COLUMN); // Snapshot Bancaire $snapshot = ['date' => date('Y-m-d'), 'amount' => 0]; try { $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']]; } } catch (Exception $e) {} $solde_actuel = $snapshot['amount']; // Lecture Budget Prévisionnel $stmt = $pdo->query("SELECT id, name, amount, type, category, is_estimate, payment_day, is_checked, mapping_keywords FROM pf_budget_items ORDER BY name ASC"); while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) { $rawAmount = (float)$item['amount']; $absAmount = abs($rawAmount); $amt = ($item['type'] === 'Annuel') ? $absAmount / 12 : $absAmount; $name = trim($item['name']); $pDay = (int)$item['payment_day']; $isChecked = (int)$item['is_checked']; if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) { $fixedChargesList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount]; } if ($item['category'] === 'income') { $incomeList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount]; $total_income += $amt; $budget_income_prevu += $amt; } else { $total_expenses_prevues += $amt; if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) { $isPaid = false; if ($isChecked === 1) $isPaid = true; elseif (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 += $absAmount; } if ($name === 'Estimacio F&B & beauty') $budget_fmcg = $amt; elseif ($name === 'Estimacio escola') $budget_school = $amt; elseif ($name === 'Estimation gasolina') $budget_essence = $amt; elseif ((int)$item['is_estimate'] === 0 && $item['type'] === 'Mensuel' && $item['category'] === 'expense') { $budget_frais += $absAmount; } } } $tempCats = []; $total_temp_budget = 0; try { $stmt = $pdo->prepare("SELECT * FROM pf_monthly_categories WHERE month_year = ?"); $stmt->execute([$currentMonthKey]); $tempCats = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach($tempCats as $tc) { if ($tc['type'] === 'debit') $total_temp_budget += abs($tc['budget']); } } catch (Exception $e) {} $budget_autres = $total_income - ($total_expenses_prevues + $total_temp_budget); if ($budget_autres < 0) $budget_autres = 0; $categoriesConfig = [ 'Income' => ['type'=>'credit', 'label'=>'Revenus', 'budget'=>$budget_income_prevu, 'color'=>'#10b981', 'suggestions'=>[]], 'FMCG' => ['type'=>'debit', 'label'=>'Courses (FMCG)', 'budget'=>$budget_fmcg, 'color'=>'#3b82f6', 'suggestions'=>['Action', 'Carrefour', 'Lidl']], 'Essence' => ['type'=>'debit', 'label'=>'Essence', 'budget'=>$budget_essence, 'color'=>'#f59e0b', 'suggestions'=>['Audi', 'Polo']], 'School' => ['type'=>'debit', 'label'=>'École / Garde', 'budget'=>$budget_school, 'color'=>'#10b981', 'suggestions'=>[]], 'Frais' => ['type'=>'debit', 'label'=>'Charges Fixes', 'budget'=>$budget_frais, 'color'=>'#ef4444', 'suggestions'=>[]], ]; $tempColors = ['#ec4899', '#06b6d4', '#84cc16', '#d946ef', '#f97316']; $colorIdx = 0; foreach ($tempCats as $tc) { $catKey = 'TEMP_' . $tc['id']; $categoriesConfig[$catKey] = [ 'type' => $tc['type'], 'label' => $tc['name'], 'budget' => abs($tc['budget']), 'color' => $tempColors[$colorIdx++ % count($tempColors)], 'suggestions' => [], 'is_temp' => true, 'id' => $tc['id'] ]; } $categoriesConfig['Autres'] = ['type'=>'debit', 'label'=>'Autres / Imprévus', 'budget'=>$budget_autres, 'color'=>'#64748b', 'suggestions'=>['Restaurant', 'Cadeau']]; $categoriesConfig['LivretA'] = ['type'=>'debit', 'label'=>'Epargne', 'budget'=>0, 'color'=>'#8b5cf6', 'suggestions'=>['Virement']]; // ============================================================================ // 4. DONNÉES RÉELLES (Sur la période du cycle) // ============================================================================ $csvData = []; $showPreview = false; // ... (Gestion CSV Upload inchangée ici) if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) { $file = $_FILES['csv_file']['tmp_name']; $handle = fopen($file, "r"); $rules = []; try { $rules = $pdo->query("SELECT keyword, category FROM pf_import_rules")->fetchAll(PDO::FETCH_KEY_PAIR); } 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 = ''; foreach ($rules as $kw => $c) { if (stripos($label, $kw) !== false) { $suggestedCat = $c; break; } } $csvData[] = ['date'=>$dateSql, 'label'=>$label, 'amount'=>$amount, 'cat'=>$suggestedCat, 'ref'=>$uniqueKey, 'is_duplicate'=>$isDuplicate, 'is_credit'=>$isCredit]; } fclose($handle); $showPreview = true; } // MODIFICATION : Dépenses ciblées sur les bornes du cycle ! $stmt = $pdo->prepare("SELECT * FROM pf_expenses WHERE date_exp >= ? AND date_exp <= ? ORDER BY date_exp DESC"); $stmt->execute([$customStartDate, $customEndDate]); $allExpenses = $stmt->fetchAll(PDO::FETCH_ASSOC); $totals = array_fill_keys(array_keys($categoriesConfig), 0); $expensesByCategory = array_fill_keys(array_keys($categoriesConfig), []); // --- PREPARATION SOLDE THEORIQUE SUR LE CYCLE --- $solde_initial = $customStartBalance; $total_rentrees = 0; $depenses_reelles = 0; foreach ($allExpenses as $exp) { $cat = $exp['category']; if (!isset($totals[$cat])) $cat = 'Autres'; $val = (float)$exp['amount']; // 1. UI Cards if ($cat === 'Income') { $totals[$cat] += $val; } else { if ($val > 0) $categoriesConfig[$cat]['budget'] += $val; else $totals[$cat] += abs($val); } $expensesByCategory[$cat][] = $exp; // 2. Mathématiques pures if ($val > 0) { if ($cat === 'Income' || $cat !== 'Frais') { $total_rentrees += $val; // Rentrées d'argent } else { $depenses_reelles -= $val; // Remboursement d'une charge fixe } } else { $depenses_reelles += abs($val); } } // 3. Calcul de la Capacité Max Dynamique $rentrees_salaires_reels = $totals['Income']; $rentrees_autres = $total_rentrees - $rentrees_salaires_reels; // Capacité Max = Solde de départ + (le plus grand entre le salaire prévu et reçu) + rentrées annexes $capacite_max = $solde_initial + max($rentrees_salaires_reels, $budget_income_prevu) + $rentrees_autres; // SOLDE THÉORIQUE = Solde initial + Toutes les rentrées réelles - Dépenses - Charges à venir $solde_theorique = ($solde_initial + $total_rentrees) - $depenses_reelles - $reste_a_venir; $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) { if ($type === 'credit') { $pct = ($bg > 0) ? min(100, ($spent / $bg) * 100) : ($spent > 0 ? 100 : 0); $isOver = false; if ($bg > 0) { $text = number_format(ceil($spent), 0, ',', ' ') . ' / ' . number_format(ceil($bg), 0, ',', ' ') . ' €'; } else { $text = number_format(ceil($spent), 0, ',', ' ') . ' €'; } } else { $pct = ($bg > 0) ? min(100, ($spent / $bg) * 100) : ($spent > 0 ? 100 : 0); $isOver = ($spent > $bg && $bg > 0); if ($bg > 0) { $text = number_format(ceil($spent), 0, ',', ' ') . ' / ' . number_format(ceil($bg), 0, ',', ' ') . ' €'; } else { $text = number_format(ceil($spent), 0, ',', ' ') . ' €'; } } return ['pct' => $pct, 'isOver' => $isOver, 'text' => $text]; } $moisFr = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; $monthName = $moisFr[(int)$currentMonth] . ' ' . $currentYear; $cycleDisplay = date('d/m', strtotime($customStartDate)) . ' au ' . date('d/m', strtotime($customEndDate)); ?>

Suivi :

Cycle du
Solde bancaire (au )
Solde théorique (Fin de cycle)
Charges fixes à venir
-
0 €
Capacité Max :
Actuel
Théorique
Dispo Net : Provisions :

Valider l'importation

$row): $dup = $row['is_duplicate']; $isCrd = $row['is_credit']; $dis = $dup?'disabled':''; $bgCol = $dup ? 'opacity:0.5' : (empty($row['cat']) && !$isCrd ? 'background:#fff1f2' : ''); ?>
DateLibelléMontantCatégorie
onchange="checkValidation()">
(déjà importé)
Annuler
$conf): ?>

×

Aucune ligne.
0): ?>
🔗 + - ×

Nouvelle transaction

Mettre à jour le solde

Configuration du Cycle

Le cycle démarre généralement à la réception de votre paie principale. Le solde initial que vous déclarez deviendra votre Capacité Max de départ pour ce mois.
Solde bancaire exact avant toute nouvelle rentrée d'argent de ce mois.

Nouvelle catégorie