This commit is contained in:
2026-03-05 09:55:37 +01:00
parent 7060a43859
commit 27564c8e9e
5 changed files with 354 additions and 123 deletions
+95
View File
@@ -1016,3 +1016,98 @@ th.col-laia {
align-items: center;
}
}
/* ============================================================================
13. STYLES SPÉCIFIQUES AU SUIVI MENSUEL (suivi.php)
============================================================================ */
.cat-card {
background: var(--bg-card);
border-radius: var(--radius-l);
border: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.btn-add-item {
background: rgba(255, 255, 255, 0.6);
color: inherit;
border: 1px solid rgba(0, 0, 0, 0.1);
width: 28px;
height: 28px;
border-radius: 50%;
font-size: 18px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.btn-add-item:hover {
background: var(--bg-card);
transform: rotate(90deg) scale(1.1);
box-shadow: var(--shadow-sm);
border-color: currentColor;
}
.progress-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 20px;
}
/* Animation "Charges à venir" */
.fade-pulse {
animation: pulseText 2s infinite;
}
@keyframes pulseText {
0% {
opacity: 0.8;
}
50% {
opacity: 1;
}
100% {
opacity: 0.8;
}
}
/* Navigation par mois (Si elle n'est pas déjà gérée par .nav-group au-dessus) */
.suivi-nav-group {
display: flex;
background: #e2e8f0;
border-radius: var(--radius-s);
overflow: hidden;
}
.suivi-btn-nav {
padding: 6px 12px;
color: var(--text-muted);
text-decoration: none;
font-size: 0.85rem;
font-weight: bold;
border-right: 1px solid #cbd5e1;
transition:
background 0.2s,
color 0.2s;
}
.suivi-btn-nav:last-child {
border-right: none;
}
.suivi-btn-nav:hover {
background: var(--bg-card);
color: var(--primary);
}
/* Sélecteur dynamique de mois (dans l'import CSV) */
input[type="month"].pf-input {
font-family: inherit;
color: var(--text-main);
background: white;
border: 1px solid #cbd5e1;
border-radius: var(--radius-s);
}
@@ -8,6 +8,38 @@ require_login();
$action = $_POST['action'] ?? '';
// =================================================================
// 7. SAUVEGARDE D'UNE NOTE GÉNÉRIQUE (pf_notes)
// =================================================================
if ($action === 'save_note') {
// On affiche les erreurs s'il y a un souci SQL pour pouvoir déboguer
ini_set('display_errors', 1);
error_reporting(E_ALL);
header('Content-Type: application/json');
try {
$noteType = $_POST['note_type'] ?? '';
$refId = $_POST['reference_id'] ?? '';
$content = $_POST['content'] ?? '';
if (empty($noteType) || empty($refId)) {
throw new Exception("Le type et la référence de la note sont requis.");
}
// Insère ou met à jour la note si elle existe déjà
$stmt = $pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE content = VALUES(content)");
$stmt->execute([$noteType, $refId, $content]);
echo json_encode(['success' => true]);
} catch (\Throwable $e) {
echo json_encode(['success' => false, 'error' => 'Erreur SQL: ' . $e->getMessage()]);
}
exit;
}
// =================================================================
// 1. MISE A JOUR TABLEAU SALAIRES (AJAX)
if ($action === 'update_salary_config') {
header('Content-Type: application/json'); // On précise JSON ici
@@ -207,4 +239,6 @@ if ($action === 'validate_transfers') {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
+73 -1
View File
@@ -69,6 +69,10 @@ if ($sysCatId && isset($allocs[$focusDate][$sysCatId])) {
$isValidatedLaia = ($row['amount_laia'] == 1);
}
$stmtNote = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'budget_prev' AND reference_id = ?");
$stmtNote->execute([$focusDate]);
$currentNote = $stmtNote->fetchColumn();
// 7. Récupération des vacances actives pour les lier au budget
$activeHolidays = $pdo->query("SELECT id, title FROM pf_holidays WHERE status IN ('draft', 'planned', 'booked') ORDER BY start_date ASC")->fetchAll(PDO::FETCH_ASSOC);
?>
@@ -249,13 +253,42 @@ $activeHolidays = $pdo->query("SELECT id, title FROM pf_holidays WHERE status IN
</table>
</div>
</div>
<div style="margin: 20px 0; background: white; padding: 20px; border-radius: 12px; box-shadow: var(--shadow-sm); border: 1px solid #e2e8f0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; font-size: 1.1rem; color: #1e293b;">
📝 Notes pour <?= date('F Y', strtotime($focusDate)) ?>
</h3>
<span id="note-save-indicator" style="font-size:0.85rem; color:#10b981; font-weight:bold; opacity:0; transition:opacity 0.3s;">
✓ Enregistré
</span>
</div>
<textarea
id="monthNoteArea"
class="pf-input"
rows="3"
placeholder="Écrivez vos remarques, rappels ou commentaires pour ce mois ici..."
style="width: 100%; resize: vertical; border-color: #cbd5e1; background: #f8fafc; margin-bottom: 10px;"
><?= htmlspecialchars((string)$currentNote) ?></textarea>
<div style="text-align: right;">
<button type="button" class="pf-btn" style="width: auto; display: inline-flex;"
onclick="saveGenericNote('budget_prev', '<?= $focusDate ?>', document.getElementById('monthNoteArea').value)">
Enregistrer la note
</button>
</div>
</div>
<?php
$focusMonth = $months[0]; // Mois affiché à gauche
// 1. Initialisation des cibles par défaut
$targetsOrder = ['vers commune', 'vers L.Pol', 'vers L.Pep', 'vers L.Perso'];
// 2. CORRECTION : On reconstruit la liste complète $allTargets
// 2. On reconstruit la liste complète $allTargets
// On part des cibles par défaut
$allTargets = $targetsOrder;
@@ -744,4 +777,43 @@ function validateTransfers(person, month) {
})
.catch(e => alert("Erreur technique"));
}
// --- SAUVEGARDE GÉNÉRIQUE DES NOTES ---
function saveGenericNote(noteType, refId, content) {
const formData = new FormData();
formData.append('action', 'save_note');
formData.append('note_type', noteType); // ex: 'budget_prev'
formData.append('reference_id', refId); // ex: '2026-03-01'
formData.append('content', content); // Le texte
fetch('/modules/budget/includes/api/save-budget.php', {
method: 'POST',
body: formData
})
.then(async r => {
const text = await r.text();
if (!r.ok) throw new Error(`Erreur HTTP ${r.status} : ${text}`);
if (!text) throw new Error("Le serveur a renvoyé une réponse vide.");
try {
return JSON.parse(text);
} catch(e) {
throw new Error("Réponse inattendue (non JSON) : " + text);
}
})
.then(data => {
if(data.success) {
const indicator = document.getElementById('note-save-indicator');
if(indicator) {
indicator.style.opacity = '1';
setTimeout(() => indicator.style.opacity = '0', 2000);
}
} else {
alert("Erreur lors de la sauvegarde : " + data.error);
}
})
.catch(e => {
console.error('Erreur technique:', e);
alert(e.message);
});
}
</script>
+29 -70
View File
@@ -5,11 +5,10 @@
$stmt = $pdo->query("SELECT * FROM pf_budget_items ORDER BY category DESC, sort_order ASC, name ASC");
$items = $stmt->fetchAll();
// 2. Récupération des Dépenses Réelles du mois
// 2. Récupération des Dépenses Réelles du mois (Pour valider les états)
$currentMonth = date('m');
$currentYear = date('Y');
// On récupère TOUTES les dépenses du mois pour faire le calcul en PHP
$stmtExp = $pdo->prepare("SELECT amount, label, budget_item_id FROM pf_expenses WHERE MONTH(date_exp) = ? AND YEAR(date_exp) = ?");
$stmtExp->execute([$currentMonth, $currentYear]);
$allExpenses = $stmtExp->fetchAll(PDO::FETCH_ASSOC);
@@ -32,8 +31,7 @@ $totalRevenus = 0;
<th>Montant Prévu</th>
<th>Type</th>
<th>Jour</th>
<th>État</th>
<th>Régularisation</th>
<th>État du mois (<?= date('M') ?>)</th>
<th style="text-align:right;">Actions</th>
</tr>
</thead>
@@ -51,11 +49,9 @@ $totalRevenus = 0;
foreach ($allExpenses as $exp) {
$match = false;
// A. Matching par ID (Prioritaire)
if (!empty($exp['budget_item_id']) && (int)$exp['budget_item_id'] === (int)$item['id']) {
$match = true;
}
// B. Matching par Mots-clés
elseif (empty($exp['budget_item_id']) && !empty($item['mapping_keywords'])) {
$keywords = array_map('trim', explode(',', $item['mapping_keywords']));
foreach ($keywords as $kw) {
@@ -67,31 +63,22 @@ $totalRevenus = 0;
}
if ($match) {
// CORRECTION : Gestion des signes
if ($item['category'] === 'income') {
// Pour les revenus stockés en négatif, on prend la valeur absolue
$realSum += abs((float)$exp['amount']);
} else {
// Pour les dépenses, on garde le signe (Débit positif, Remboursement négatif)
$realSum += (float)$exp['amount'];
}
$hasMatchingExpense = true;
}
}
// --- 3. LOGIQUE D'ÉTAT (AUTO-CHECK) ---
// On coche si la somme réelle atteint au moins 98% du montant prévu (tolérance petit écart)
// ou si c'est coché manuellement.
$isAutoChecked = false;
// --- 3. LOGIQUE D'ÉTAT (Uniquement dynamique) ---
$gap = $realSum - $item['amount'];
// Seuil de tolérance (ex: 0.10€) pour considérer que c'est payé
$isAutoChecked = false;
if ($hasMatchingExpense && ($realSum >= ($item['amount'] - 0.10))) {
$isAutoChecked = true;
}
$isPaid = $item['is_checked'] || $isAutoChecked;
// Styles
$rowClass = ($item['category'] === 'income') ? 'row-income' : 'row-expense';
if ($item['is_estimate']) $rowClass .= ' row-estimate';
@@ -100,9 +87,16 @@ $totalRevenus = 0;
<td style="padding:15px;">
<strong><?= htmlspecialchars($item['name']) ?></strong>
<?= $item['is_estimate'] ? ' <small style="color:#64748b;">(Est.)</small>' : '' ?>
<?php if(!empty($item['mapping_keywords'])): ?>
<span title="<?= htmlspecialchars($item['mapping_keywords']) ?>" style="font-size:0.7rem; cursor:help;">🔗</span>
<?php endif; ?>
<?php if(!empty($item['reg_month'])): ?>
<div style="font-size:0.75rem; color:#94a3b8; font-style:italic; margin-top:2px;">
📅 Régul. prévue en <?= htmlspecialchars($item['reg_month']) ?>
</div>
<?php endif; ?>
</td>
<td class="cell-amount" style="font-weight:600; padding:15px; color:<?= $item['category']==='income'?'#10b981':'#1e293b' ?>;">
@@ -122,7 +116,6 @@ $totalRevenus = 0;
Montant exact ✓
</div>
<?php endif; ?>
<?php elseif ($item['type'] === 'Annuel'): ?>
<div style="font-size:0.75rem; color:#94a3b8; font-weight:normal;">Soit <?= number_format($amountToAdd, 2, ',', ' ') ?>/mois</div>
<?php endif; ?>
@@ -133,33 +126,24 @@ $totalRevenus = 0;
<?= $item['type'] ?>
</span>
</td>
<td style="padding:15px; color:#64748b;"><?= $item['payment_day'] ? $item['payment_day'] : '-' ?></td>
<td style="padding:15px; color:#64748b; font-weight:bold;"><?= $item['payment_day'] ? $item['payment_day'] : '-' ?></td>
<td style="padding:15px;">
<div style="display:flex; align-items:center; gap:8px;">
<?php if ($isAutoChecked): ?>
<input type="checkbox" checked disabled style="width:18px; height:18px; accent-color:#3b82f6; cursor:not-allowed;">
<span style="color:#3b82f6; font-weight:600; font-size:0.9rem;">Auto ✅</span>
<?php else: ?>
<input type="checkbox"
<?= $item['is_checked'] ? 'checked' : '' ?>
onclick="toggleItemCheck(<?= $item['id'] ?>, this.checked)"
title="Marquer comme payé"
style="width:18px; height:18px; cursor:pointer;">
<?php if($item['is_checked']): ?>
<span style="color:#10b981; font-weight:500; font-size:0.9rem;">Payé</span>
<?php elseif($hasMatchingExpense): ?>
<span style="color:#f59e0b; font-weight:bold; font-size:0.8rem;">Partiel</span>
<?php else: ?>
<span style="color:#94a3b8; font-weight:500; font-size:0.9rem;">Attente</span>
<?php endif; ?>
<?php endif; ?>
</div>
<?php if ($isAutoChecked): ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#f0fdf4; color:#16a34a; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #bbf7d0;">
<span>✓</span> Validé
</div>
<?php elseif($hasMatchingExpense): ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#fffbeb; color:#d97706; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #fde68a;">
<span>⏳</span> Partiel
</div>
<?php else: ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#f8fafc; color:#94a3b8; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #e2e8f0;">
<span>○</span> En attente
</div>
<?php endif; ?>
</td>
<td style="padding:15px; color:#64748b; font-size:0.9rem;">
<em><?= htmlspecialchars($item['reg_month'] ?: '-') ?></em>
</td>
<td style="padding:15px; text-align:right;">
<div class="action-buttons" style="display:flex; gap:5px; justify-content:flex-end;">
<button class="btn-icon" onclick='editRecapItem(<?= htmlspecialchars(json_encode($item), ENT_QUOTES, 'UTF-8') ?>)' title="Modifier" style="background:none; border:none; cursor:pointer; font-size:1.1rem; filter:grayscale(1); transition:0.2s;">✏️</button>
@@ -172,16 +156,16 @@ $totalRevenus = 0;
<tfoot style="background:#f8fafc;">
<tr>
<td colspan="1" style="padding:15px;"><strong>Total Revenus (Lissés)</strong></td>
<td colspan="6" style="padding:15px; color:#10b981;"><strong>+ <?= number_format($totalRevenus, 2, ',', ' ') ?> €</strong></td>
<td colspan="5" style="padding:15px; color:#10b981;"><strong>+ <?= number_format($totalRevenus, 2, ',', ' ') ?> €</strong></td>
</tr>
<tr>
<td colspan="1" style="padding:15px;"><strong>Total Dépenses (Lissées)</strong></td>
<td colspan="6" style="padding:15px; color:#ef4444;"><strong>- <?= number_format($totalDepenses, 2, ',', ' ') ?> €</strong></td>
<td colspan="5" style="padding:15px; color:#ef4444;"><strong>- <?= number_format($totalDepenses, 2, ',', ' ') ?> €</strong></td>
</tr>
<tr style="border-top: 2px solid #e2e8f0; background: white;">
<td colspan="1" style="padding:15px; font-size:1.1rem;"><strong>Équilibre théorique</strong></td>
<?php $balance = $totalRevenus - $totalDepenses; ?>
<td colspan="6" style="padding:15px; font-size: 1.3em;" class="<?= $balance >= 0 ? 'text-success' : 'text-danger' ?>">
<td colspan="5" style="padding:15px; font-size: 1.3em;" class="<?= $balance >= 0 ? 'text-success' : 'text-danger' ?>">
<strong style="color:<?= $balance >= 0 ? '#10b981' : '#ef4444' ?>;"><?= number_format($balance, 2, ',', ' ') ?> € / mois</strong>
</td>
</tr>
@@ -190,7 +174,7 @@ $totalRevenus = 0;
</div>
<div class="budget-note" style="margin-top:15px; font-size:0.85rem; color:#64748b;">
<p>* Les lignes <strong>Auto ✅</strong> sont validées quand la somme des transactions atteint le montant prévu.</p>
<p>* L'état de paiement se met à jour automatiquement en fonction des opérations importées dans l'onglet "Suivi Mensuel".</p>
</div>
</div>
@@ -274,10 +258,6 @@ $totalRevenus = 0;
</div>
<script>
// ==========================================
// SCRIPTS ISOLÉS POUR L'ONGLET RECAPITULATIF
// ==========================================
window.onclick = function(event) {
const modal = document.getElementById('budgetRecapModal');
if (event.target == modal) {
@@ -332,25 +312,4 @@ function deleteRecapItem(id) {
});
}
}
function toggleItemCheck(id, isChecked) {
const formData = new FormData();
formData.append("action", "toggle-check");
formData.append("id", id);
formData.append("status", isChecked ? 1 : 0);
fetch("/modules/budget/includes/api/manage-item.php", {
method: "POST",
body: formData,
})
.then((response) => {
if (!response.ok) throw new Error("Erreur réseau");
window.location.reload();
})
.catch((error) => {
alert("Erreur lors de la mise à jour");
console.error(error);
window.location.reload();
});
}
</script>
+123 -52
View File
@@ -2,10 +2,24 @@
// modules/budget/views/suivi.php
// ============================================================================
// 1. GESTION DES ACTIONS (POST/GET)
// 1. GESTION DES ACTIONS ET DE LA NAVIGATION
// ============================================================================
$currentMonthKey = date('m-Y');
// --- NAVIGATION PAR MOIS ---
$currentMonth = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m');
$currentYear = isset($_GET['y']) ? (int)$_GET['y'] : date('Y');
$currentMonthKey = $currentMonth . '-' . $currentYear;
// Calcul des liens de navigation
$prevM = (int)$currentMonth - 1; $prevY = $currentYear;
if ($prevM < 1) { $prevM = 12; $prevY--; }
$nextM = (int)$currentMonth + 1; $nextY = $currentYear;
if ($nextM > 12) { $nextM = 1; $nextY++; }
$prevLink = "?tab=suivi&m=$prevM&y=$prevY";
$nextLink = "?tab=suivi&m=$nextM&y=$nextY";
$todayLink = "?tab=suivi";
// A. AJOUT CATÉGORIE TEMPORAIRE MANUELLE
if (isset($_POST['action']) && $_POST['action'] === 'add_temp_cat') {
@@ -16,14 +30,14 @@ if (isset($_POST['action']) && $_POST['action'] === 'add_temp_cat') {
if ($name && $budget >= 0) {
$stmt = $pdo->prepare("INSERT INTO pf_monthly_categories (month_year, name, type, budget) VALUES (?, ?, ?, ?)");
$stmt->execute([$currentMonthKey, $name, $type, $budget]);
header("Location: ?tab=suivi"); exit;
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"); exit;
header("Location: ?tab=suivi&m=$currentMonth&y=$currentYear"); exit;
}
// C. SAUVEGARDE SNAPSHOT BANCAIRE
@@ -32,14 +46,18 @@ if (isset($_POST['action']) && $_POST['action'] === 'save_snapshot') {
$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"); exit;
header("Location: ?tab=suivi&m=$currentMonth&y=$currentYear"); exit;
}
// D. SAUVEGARDE IMPORT CSV (AVEC BUDGET_ITEM_ID ET HOLIDAY_ID)
// D. SAUVEGARDE IMPORT CSV (LIGNE PAR LIGNE AVEC CHECKBOX)
if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
$count = 0;
// 1. Catégories temporaires (Inchangé)
// On récupère le mois affiché sur l'écran lors de l'import
$viewMonth = $_POST['view_month'] ?? $currentMonth;
$viewYear = $_POST['view_year'] ?? $currentYear;
// 1. Catégories temporaires
$tempCatMapping = [];
if (!empty($_POST['new_temp_cats'])) {
$stmtTemp = $pdo->prepare("INSERT INTO pf_monthly_categories (month_year, name, type, budget) VALUES (?, ?, ?, 0)");
@@ -49,7 +67,6 @@ if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
}
}
// 2. Insertion avec budget_item_id ET holiday_id
$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)");
@@ -59,7 +76,7 @@ if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
$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; // NOUVEAU
$holidayId = !empty($line['holiday_id']) ? (int)$line['holiday_id'] : null;
if ($is_credit && empty($cat)) continue;
if (!$is_credit && empty($cat)) continue;
@@ -69,16 +86,30 @@ if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
}
$finalAmount = $is_credit ? -abs($line['amount']) : abs($line['amount']);
// NOUVEAU : GESTION DU DÉCALAGE SI LA COCHE EST SÉLECTIONNÉE
$dateToSave = $line['date'];
if (!empty($line['force_current'])) {
$day = date('d', strtotime($dateToSave));
// On vérifie que la date existe (ex: pour éviter le 31 Février)
if (checkdate((int)$viewMonth, (int)$day, (int)$viewYear)) {
$dateToSave = "$viewYear-$viewMonth-$day";
} else {
// Sinon, on met au dernier jour du mois visé
$dateToSave = date('Y-m-t', strtotime("$viewYear-$viewMonth-01"));
}
}
try {
$stmtExp->execute([$line['date'], $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId]);
$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&msg=imported_$count"); exit;
header("Location: ?tab=suivi&m=$viewMonth&y=$viewYear&msg=imported_$count"); exit;
}
// E. AJOUT OU MODIFICATION DÉPENSE (MANUELLE)
@@ -90,7 +121,7 @@ if (isset($_POST['action']) && $_POST['action'] === 'save_expense_manual') {
$label = trim($_POST['label']);
$budgetItemId = null;
$holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; // NOUVEAU
$holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
if ($cat === 'School' && !empty($_POST['label_select'])) {
$label = trim($_POST['label_select']);
@@ -102,24 +133,23 @@ if (isset($_POST['action']) && $_POST['action'] === 'save_expense_manual') {
if ($label && $amount > 0) {
$is_credit = isset($_POST['is_credit']) ? (int)$_POST['is_credit'] : 0;
$finalAmount = $is_credit ? -abs($amount) : abs($amount);
if ($id) {
// UPDATE
$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 {
// INSERT
$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"); exit;
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"); exit;
header("Location: ?tab=suivi&m=$currentMonth&y=$currentYear"); exit;
}
// G. RECUPERATION VACANCES
@@ -137,15 +167,13 @@ $today_day = (int)date('j');
$fixedChargesList = [];
$incomeList = [];
$currentMonth = date('m'); $currentYear = date('Y');
// 1. Récupération des IDs payés
$stmtIds = $pdo->prepare("SELECT DISTINCT budget_item_id FROM pf_expenses WHERE MONTH(date_exp) = ? AND YEAR(date_exp) = ? AND budget_item_id IS NOT NULL");
// 1. Récupération des IDs de charges fixes déjà payées (Dépenses uniquement)
$stmtIds = $pdo->prepare("SELECT DISTINCT budget_item_id FROM pf_expenses WHERE MONTH(date_exp) = ? AND YEAR(date_exp) = ? AND budget_item_id IS NOT NULL AND amount > 0");
$stmtIds->execute([$currentMonth, $currentYear]);
$paidItemIds = $stmtIds->fetchAll(PDO::FETCH_COLUMN);
// 2. Récupération libellés
$stmtLabels = $pdo->prepare("SELECT label FROM pf_expenses WHERE MONTH(date_exp) = ? AND YEAR(date_exp) = ?");
// 2. Récupération libellés (Uniquement les dépenses pour éviter qu'un revenu ne valide une charge)
$stmtLabels = $pdo->prepare("SELECT label FROM pf_expenses WHERE MONTH(date_exp) = ? AND YEAR(date_exp) = ? AND amount > 0");
$stmtLabels->execute([$currentMonth, $currentYear]);
$realExpensesLabels = $stmtLabels->fetchAll(PDO::FETCH_COLUMN);
@@ -168,7 +196,7 @@ if (!empty($snapshot['date'])) {
} catch (Exception $e) {}
}
// Lecture Budget
// 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'];
@@ -322,7 +350,6 @@ function getDisplayLogic($spent, $bg, $type) {
$pct = ($bg > 0) ? min(100, ($spent / $bg) * 100) : ($spent > 0 ? 100 : 0);
$isOver = ($spent > $bg && $bg > 0);
// MODIFICATION ICI : On cache le "/ 0 €" si le budget est à 0
if ($bg > 0) {
$text = number_format(ceil($spent), 0, ',', ' ') . ' / ' . number_format(ceil($bg), 0, ',', ' ') . ' €';
} else {
@@ -331,25 +358,27 @@ function getDisplayLogic($spent, $bg, $type) {
}
return ['pct' => $pct, 'isOver' => $isOver, 'text' => $text];
}
// Nom du mois en français (Compatible PHP 8+)
$moisFr = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
$monthName = $moisFr[(int)$currentMonth] . ' ' . $currentYear;
?>
<style>
.cat-card { background: white; border-radius: 16px; border: 1px solid #e2e8f0; display: flex; flex-direction: column; overflow: hidden; position: relative; }
.btn-add-item { background: rgba(255, 255, 255, 0.6); color: inherit; border: 1px solid rgba(0, 0, 0, 0.1); width: 28px; height: 28px; border-radius: 50%; font-size: 18px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
.btn-add-item:hover { background: white; transform: rotate(90deg) scale(1.1); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); border-color: currentColor; }
.pf-modal { display: none; position: fixed; inset: 0; z-index: 9999; background: rgba(15, 23, 42, 0.6); backdrop-filter: blur(4px); align-items: center; justify-content: center; }
.pf-modal-content { background: white; width: 95%; border-radius: 20px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); padding: 30px; position: relative; }
.progress-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-top: 20px; }
.fade-pulse { animation: pulseText 2s infinite; }
@keyframes pulseText { 0% { opacity: 0.8; } 50% { opacity: 1; } 100% { opacity: 0.8; } }
</style>
<div class="budget-view">
<div style="background:white; padding:20px; border-radius:16px; box-shadow:var(--shadow-sm); margin-bottom:30px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; flex-wrap:wrap; gap:10px;">
<div>
<h2 style="margin:0;">Suivi : <?= date('F Y') ?></h2>
<div style="display:flex; align-items:center; gap:15px; margin-bottom:5px;">
<h2 style="margin:0; text-transform:capitalize;">Suivi : <?= $monthName ?></h2>
<div class="suivi-nav-group">
<a href="<?= $prevLink ?>" class="suivi-btn-nav">◀</a>
<a href="<?= $todayLink ?>" class="suivi-btn-nav">Auj.</a>
<a href="<?= $nextLink ?>" class="suivi-btn-nav">▶</a>
</div>
</div>
<div style="margin-top:4px; font-size:0.9rem; color:#64748b;">
Charges à venir : <strong class="fade-pulse" style="color:#f59e0b;"><?= number_format(ceil($reste_a_venir), 0, ',', ' ') ?> €</strong>
</div>
@@ -427,10 +456,16 @@ function getDisplayLogic($spent, $bg, $type) {
<?php else: ?>
<form method="POST" id="formMapping">
<input type="hidden" name="action" value="save_import">
<input type="hidden" name="view_month" value="<?= $currentMonth ?>">
<input type="hidden" name="view_year" value="<?= $currentYear ?>">
<div id="dynamicNewCats"></div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<h3 style="margin:0;">Valider l'importation</h3>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; flex-wrap:wrap; gap:10px;">
<div style="display:flex; align-items:center; gap:15px;">
<h3 style="margin:0;">Valider l'importation</h3>
</div>
<div>
<span id="missingCount" style="color:#ef4444; background:#fee2e2; padding:4px 10px; border-radius:12px; font-weight:bold; font-size:0.85rem; display:none; margin-right:10px;"></span>
<button type="button" class="btn-icon-small" onclick="openSuiviModal('newCatModal')" title="Créer une catégorie" style="display:inline-flex; vertical-align:middle; width:auto; padding:4px 10px;"> Catégorie</button>
@@ -440,7 +475,7 @@ function getDisplayLogic($spent, $bg, $type) {
<div style="max-height:350px; overflow-y:auto; background:white; border:1px solid #eee; border-radius:8px;">
<table class="pf-table" style="margin:0;">
<thead style="position:sticky; top:0; z-index:10;">
<tr><th><input type="checkbox" onclick="toggleAll(this)" checked></th><th>Libellé</th><th>Montant</th><th>Catégorie</th></tr>
<tr><th><input type="checkbox" onclick="toggleAll(this)" checked></th><th>Date</th><th>Libellé</th><th>Montant</th><th>Catégorie</th></tr>
</thead>
<tbody>
<?php foreach ($csvData as $idx => $row):
@@ -456,11 +491,27 @@ function getDisplayLogic($spent, $bg, $type) {
<input type="hidden" name="lines[<?= $idx ?>][ref]" value="<?= $row['ref'] ?>">
<input type="hidden" class="is-credit-flag" name="lines[<?= $idx ?>][is_credit]" value="<?= $isCrd ?>">
</td>
<td style="white-space:nowrap; vertical-align: top; padding-top: 12px;">
<div style="font-weight:600; color:#1e293b; line-height:1;"><?= date('d/m/Y', strtotime($row['date'])) ?></div>
<?php
// On vérifie si la transaction appartient à un autre mois que celui affiché
$transMonthStr = date('m-Y', strtotime($row['date']));
$viewMonthStr = str_pad($currentMonth, 2, '0', STR_PAD_LEFT) . '-' . $currentYear;
if ($transMonthStr !== $viewMonthStr):
?>
<label style="display:inline-flex; align-items:center; margin-top:6px; font-size:0.7rem; color:#4f46e5; cursor:pointer; background:#e0e7ff; padding:2px 6px; border-radius:4px; border:1px solid #c7d2fe; transition:all 0.2s;" onmouseover="this.style.background='#c7d2fe'" onmouseout="this.style.background='#e0e7ff'">
<input type="checkbox" name="lines[<?= $idx ?>][force_current]" value="1" style="appearance:auto; width:12px; height:12px; margin:0 4px 0 0; border:none;">
→ <?= $moisFr[(int)$currentMonth] ?>
</label>
<?php endif; ?>
</td>
<td>
<?= htmlspecialchars($row['label']) ?>
<?php if($dup): ?><small style="color:#ef4444; font-weight:bold; margin-left:5px;">(déjà importé)</small><?php endif; ?>
</td>
<td style="font-weight:bold; color:<?= $isCrd ? '#10b981' : '#1e293b' ?>;">
<td style="font-weight:bold; color:<?= $isCrd ? '#10b981' : '#1e293b' ?>; white-space:nowrap;">
<?= $isCrd ? '+' : '-' ?> <?= number_format($row['amount'],2) ?> €
</td>
<td>
@@ -485,7 +536,8 @@ function getDisplayLogic($spent, $bg, $type) {
<option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?> (<?= number_format($inc['amount'],0) ?>€)</option>
<?php endforeach; ?>
</select>
<select name="lines[<?= $idx ?>][holiday_id]" class="pf-input" style="flex:1; border-color:#8b5cf6; background:#f5f3ff;" <?= $dis ?>>
<select name="lines[<?= $idx ?>][holiday_id]" class="pf-input select-holiday" style="display:none; flex:1; border-color:#8b5cf6; background:#f5f3ff;" <?= $dis ?>>
<option value="">-- Voyage (Optionnel) --</option>
<?php foreach ($activeHolidays as $hol): ?>
<option value="<?= $hol['id'] ?>"><?= htmlspecialchars($hol['title']) ?></option>
@@ -514,7 +566,7 @@ function getDisplayLogic($spent, $bg, $type) {
<h3 style="margin:0; font-size:1rem; color:<?= $conf['color'] ?>;">
<?= $conf['label'] ?>
<?php if (isset($conf['is_temp'])): ?>
<a href="?tab=suivi&del_cat=<?= $conf['id'] ?>" onclick="return confirm('Supprimer cette catégorie ?')" style="color:#ef4444; text-decoration:none; font-size:0.8rem; margin-left:5px;">&times;</a>
<a href="?tab=suivi&del_cat=<?= $conf['id'] ?>&m=<?= $currentMonth ?>&y=<?= $currentYear ?>" onclick="return confirm('Supprimer cette catégorie ?')" style="color:#ef4444; text-decoration:none; font-size:0.8rem; margin-left:5px;">&times;</a>
<?php endif; ?>
</h3>
<div style="font-size:0.85rem; color:#64748b; font-weight:600; margin-top:2px;">
@@ -554,7 +606,7 @@ function getDisplayLogic($spent, $bg, $type) {
✏️
</button>
<a href="?tab=suivi&delete_expense=<?= $exp['id'] ?>"
<a href="?tab=suivi&delete_expense=<?= $exp['id'] ?>&m=<?= $currentMonth ?>&y=<?= $currentYear ?>"
onclick="return confirm('Supprimer ?')"
style="color:#ef4444; text-decoration:none; font-size:1.4rem; line-height:1; vertical-align:middle;">
&times;
@@ -637,7 +689,7 @@ function getDisplayLogic($spent, $bg, $type) {
</select>
</div>
<div class="form-group" style="margin-bottom:15px;">
<div class="form-group" id="blockHoliday" style="margin-bottom:15px; display:none;">
<label class="pf-label" style="color:#8b5cf6;">🌴 Associer à un voyage (Optionnel)</label>
<select name="holiday_id" id="modalHolidayId" class="pf-input" style="border-color:#8b5cf6; background:#f5f3ff;">
<option value="">-- Ne pas associer --</option>
@@ -718,13 +770,15 @@ window.onclick = function(event) { if (event.target.classList.contains('pf-modal
// --- SAISIE / EDITION MANUELLE ---
const suggestions = <?= json_encode(array_map(fn($c) => $c['suggestions'], $categoriesConfig)) ?>;
// Gère l'affichage des champs selon la catégorie sélectionnée dans la modale
function handleModalCatChange(select) {
const catKey = select.value;
const catText = select.options[select.selectedIndex].text.toLowerCase();
const blockText = document.getElementById('blockInputText');
const blockSelect = document.getElementById('blockInputSelect');
const blockFrais = document.getElementById('blockInputFrais');
const blockIncome = document.getElementById('blockInputIncome');
const blockHoliday = document.getElementById('blockHoliday');
const inputLabel = document.getElementById('modalLabelInput');
const selectFrais = document.getElementById('fraisSelect');
@@ -735,11 +789,19 @@ function handleModalCatChange(select) {
blockSelect.style.display = 'none';
blockFrais.style.display = 'none';
blockIncome.style.display = 'none';
blockHoliday.style.display = 'none';
inputLabel.required = false;
selectFrais.required = false;
selectIncome.required = false;
// Affichage conditionnel des Vacances (Si Epargne/LivretA OU si la catégorie contient le mot vacance)
if (catKey === 'LivretA' || catText.includes('vacance')) {
blockHoliday.style.display = 'block';
} else {
document.getElementById('modalHolidayId').value = ''; // On reset si on cache
}
if (catKey === 'School') {
blockSelect.style.display = 'block';
}
@@ -757,7 +819,6 @@ function handleModalCatChange(select) {
blockText.style.display = 'block';
inputLabel.required = true;
// Update suggestions
const list = document.getElementById('modalSuggestions');
list.innerHTML = '';
if (suggestions[catKey]) {
@@ -785,13 +846,12 @@ function openAddModal(catKey, catLabel) {
catSelect.value = catKey;
handleModalCatChange(catSelect);
// Auto-sélection : Si on clique sur le + de "Revenus", on pré-sélectionne "Revenu"
document.getElementById('modalIsCredit').value = (catKey === 'Income') ? "1" : "0";
setTimeout(() => document.getElementById('modalLabelInput').focus(), 100);
}
// Ouvre la modale pour MODIFIER (NOUVEAU)
// Ouvre la modale pour MODIFIER
function openEditModal(expenseData) {
openSuiviModal('manualExpenseModal');
document.getElementById('modalTitle').innerText = "Modifier la dépense";
@@ -800,10 +860,8 @@ function openEditModal(expenseData) {
document.getElementById('modalDate').value = expenseData.date_exp;
document.getElementById('modalLabelInput').value = expenseData.label;
// NOUVEAU : On lit la vraie valeur en BDD pour définir le type (+ ou -)
const rawAmount = parseFloat(expenseData.amount);
document.getElementById('modalIsCredit').value = rawAmount < 0 ? "1" : "0";
document.getElementById('modalAmount').value = Math.abs(rawAmount);
document.getElementById('modalHolidayId').value = expenseData.holiday_id || "";
@@ -840,17 +898,30 @@ function handleLineCatChange(select) {
const row = select.closest('tr');
const fraisSelect = row.querySelector('.select-frais');
const incomeSelect = row.querySelector('.select-income');
const holidaySelect = row.querySelector('.select-holiday');
const catKey = select.value;
const catText = select.options[select.selectedIndex].text.toLowerCase();
// Reset
fraisSelect.style.display = 'none';
incomeSelect.style.display = 'none';
holidaySelect.style.display = 'none';
fraisSelect.value = '';
incomeSelect.value = '';
if (select.value === 'Frais') {
// Condition d'affichage pour les vacances
if (catKey === 'LivretA' || catText.includes('vacance')) {
holidaySelect.style.display = 'block';
} else {
holidaySelect.value = '';
}
if (catKey === 'Frais') {
fraisSelect.style.display = 'block';
}
else if (select.value === 'Income') {
else if (catKey === 'Income') {
incomeSelect.style.display = 'block';
}