+7
-1
@@ -59,12 +59,18 @@ require __DIR__ . '/header.php';
|
||||
<span class="tab-icon">📊</span>
|
||||
<span><?= tr('budget_tab_recap') ?></span>
|
||||
</a>
|
||||
|
||||
<a href="?tab=provisions" class="tab-item <?= $tab == 'provisions' ? 'active' : '' ?>">
|
||||
<span class="tab-icon">🧮</span>
|
||||
<span><?= tr('budget_tab_provisions') ?></span>
|
||||
</a>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section class="pf-section">
|
||||
<?php
|
||||
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev'];
|
||||
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev', 'provisions'];
|
||||
|
||||
if (in_array($tab, $allowedTabs)) {
|
||||
$viewPath = __DIR__ . "/modules/budget/views/" . $tab . ".php";
|
||||
|
||||
@@ -172,6 +172,18 @@ CREATE TABLE IF NOT EXISTS pf_expenses (
|
||||
salary_id INT DEFAULT NULL -- 🔥 AJOUT : Lien vers le salaire configuré
|
||||
) 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 (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
|
||||
@@ -774,6 +774,27 @@ return [
|
||||
'bud_sav_sum_mode_title' => 'Activar/Desactivar la calculadora',
|
||||
'bud_sav_selection' => 'Selecció:',
|
||||
'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
|
||||
'bud_sav_confirm_delete_month' => 'Estàs segur que vols suprimir totes les dades de %m per a %o?',
|
||||
|
||||
@@ -824,6 +824,26 @@ return [
|
||||
'bud_prev_target_ph' => 'E.g., 150',
|
||||
'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)
|
||||
// ==========================================
|
||||
|
||||
@@ -771,6 +771,34 @@ return [
|
||||
'bud_sav_sum_mode_title' => 'Activer/Désactiver la calculatrice',
|
||||
'bud_sav_selection' => 'Sélection :',
|
||||
'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
|
||||
|
||||
+69
-27
@@ -5,45 +5,87 @@ $db_host = getenv('DB_HOST') ?: '127.0.0.1';
|
||||
$db_user = getenv('DB_USER') ?: 'househub';
|
||||
$db_pass = getenv('DB_PASS') ?: 'househub_dev';
|
||||
|
||||
echo "<h1>🗺️ Migration : Refonte Modèle Voyages</h1><ul>";
|
||||
echo "<h1>🗺️ Migrations HouseHub OS</h1><ul>";
|
||||
|
||||
try {
|
||||
// On récupère toutes les familles actives dans la base meta
|
||||
$stmt = $meta_pdo->query("SELECT db_name, name FROM families WHERE is_active = 1");
|
||||
$families = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($families as $family) {
|
||||
$dbName = $family['db_name'];
|
||||
echo "<li><strong>Famille : {$family['name']} ({$dbName})</strong><ul>";
|
||||
|
||||
try {
|
||||
$pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
// Connexion PDO spécifique à la base de la famille (Multi-tenant)
|
||||
$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
|
||||
$pdo->exec("ALTER TABLE pf_holidays ADD COLUMN return_step_id INT DEFAULT NULL");
|
||||
|
||||
// 2. Mise à jour pf_holidays_items
|
||||
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN step_type VARCHAR(20) DEFAULT 'stop'");
|
||||
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN expense_context VARCHAR(20) DEFAULT NULL");
|
||||
|
||||
// 3. Migration des données (is_return -> return_step_id)
|
||||
// On cherche la première étape cochée "is_return" et on l'assigne au voyage
|
||||
$pdo->exec("
|
||||
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>";
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ✈️ MIGRATION 1 : Refonte Modèle Voyages
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE pf_holidays ADD COLUMN return_step_id INT DEFAULT NULL");
|
||||
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN step_type VARCHAR(20) DEFAULT 'stop'");
|
||||
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN expense_context VARCHAR(20) DEFAULT NULL");
|
||||
|
||||
// Migration des données
|
||||
$pdo->exec("
|
||||
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
|
||||
)
|
||||
");
|
||||
|
||||
// Nettoyage
|
||||
$pdo->exec("ALTER TABLE pf_holidays_items DROP COLUMN is_return");
|
||||
echo "<li>✅ Schéma Voyages mis à jour !</li>";
|
||||
|
||||
} catch (\PDOException $e) {
|
||||
// 1060 : Duplicate column name / 1091 : Can't drop column (already dropped)
|
||||
if (in_array($e->errorInfo[1], [1060, 1091])) {
|
||||
echo "<li>⏳ Schéma Voyages déjà à jour. (Ignoré)</li>";
|
||||
} else {
|
||||
echo "<li>⚠️ Erreur sur Voyages : " . $e->getMessage() . "</li>";
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 💰 MIGRATION 2 : Provisions & Optimisation Trésorerie (Quinzaines)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
try {
|
||||
$pdo->exec("
|
||||
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;
|
||||
");
|
||||
echo "<li>✅ Table `pf_expected_expenses` (Provisions Budget) créée/vérifiée !</li>";
|
||||
|
||||
} catch (\PDOException $e) {
|
||||
echo "<li>⚠️ Erreur sur Provisions Budget : " . $e->getMessage() . "</li>";
|
||||
}
|
||||
|
||||
} catch (\PDOException $e) {
|
||||
echo "<li>⚠️ {$family['name']} : " . $e->getMessage() . "</li>";
|
||||
echo "<li>❌ Impossible de se connecter à la base de données : " . $e->getMessage() . "</li>";
|
||||
}
|
||||
|
||||
echo "</ul></li><br>";
|
||||
}
|
||||
echo "</ul><h2>🎉 Migration terminée !</h2>";
|
||||
|
||||
echo "</ul><h2>🎉 Migrations terminées avec succès !</h2>";
|
||||
|
||||
} catch (Exception $e) {
|
||||
die("Erreur fatale : " . $e->getMessage());
|
||||
die("Erreur fatale globale : " . $e->getMessage());
|
||||
}
|
||||
?>
|
||||
@@ -1798,3 +1798,121 @@ body.sum-mode-active .sum-target {
|
||||
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()]);
|
||||
}
|
||||
@@ -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()">×</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>
|
||||
Reference in New Issue
Block a user