typhon
Deploy HouseHub / deploy (push) Successful in 2s

This commit is contained in:
2026-06-19 11:13:37 +02:00
parent 89d8ad114b
commit aad528d15b
18 changed files with 2991 additions and 2845 deletions
+26 -940
View File
@@ -4,6 +4,18 @@ require __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/i18n.php';
require_login('/login.php');
// ============================================================================
// DIAGNOSTIC ONBOARDING (Vérifie si le budget est prêt à être utilisé)
// ============================================================================
$currentYear = date('Y');
$hasCategories = ((int)$pdo->query("SELECT COUNT(*) FROM pf_budget_categories")->fetchColumn() > 0);
$hasSalaries = ((int)$pdo->query("SELECT COUNT(*) FROM pf_salary_config WHERE year = $currentYear")->fetchColumn() > 0);
$hasAccounts = ((int)$pdo->query("SELECT COUNT(*) FROM pf_bank_accounts")->fetchColumn() > 0);
$hasBudgetItems = ((int)$pdo->query("SELECT COUNT(*) FROM pf_budget_items")->fetchColumn() > 0);
// Le minimum vital pour que ça tourne
$isBudgetSetupOk = ($hasCategories && $hasSalaries && $hasAccounts);
// Gestion de l'onglet actif (par défaut 'suivi')
$tab = $_GET['tab'] ?? 'suivi';
@@ -15,11 +27,15 @@ require __DIR__ . '/header.php';
?>
<div class="pf-container">
<div class="pf-hero" style="text-align: center; margin-bottom: 30px;">
<div style="display: flex; justify-content: center; align-items: center; gap: 15px; margin-bottom: 20px;">
<h1 style="margin: 0;"><?= tr('budget_main_header') ?></h1>
<button class="btn-settings-gear" onclick="openBudgetSettings()" title="<?= tr('settings') ?>">
⚙️
<div class="pf-hero budget-hero">
<div class="budget-title-group">
<h1><?= tr('budget_main_header') ?></h1>
<!-- Bouton avec séparation claire entre l'engrenage animable et l'alerte fixe -->
<button class="btn-header-settings" onclick="openBudgetSettings()" title="<?= tr('settings') ?>">
<span class="gear-icon-anim">⚙️</span>
<?php if (!$isBudgetSetupOk || !$hasBudgetItems): ?>
<span class="alert-dot">!</span>
<?php endif; ?>
</button>
</div>
@@ -48,7 +64,6 @@ require __DIR__ . '/header.php';
<section class="pf-section">
<?php
// ROUTAGE DYNAMIQUE
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev'];
if (in_array($tab, $allowedTabs)) {
@@ -57,7 +72,7 @@ require __DIR__ . '/header.php';
if (file_exists($viewPath)) {
include $viewPath;
} else {
echo "<div style='text-align:center; padding:50px; color:#ef4444;'>";
echo "<div class='budget-error-msg'>";
echo "<h3>" . tr('budget_err_file_not_found') . "</h3>";
echo "<p>" . sprintf(tr('budget_err_file_detail'), htmlspecialchars($tab)) . "</p>";
echo "</div>";
@@ -68,937 +83,8 @@ require __DIR__ . '/header.php';
?>
</section>
</div>
<div class="budget-settings-backdrop" id="modal-budget-settings">
<div class="budget-settings-modal">
<div class="bs-header">
<h3>⚙️ <?= tr('budget_settings_title') ?></h3>
<button class="bs-close" onclick="closeBudgetSettings()">×</button>
</div>
<div class="bs-layout">
<div class="bs-sidebar">
<button class="bs-tab-btn active" onclick="switchBsTab('accounts', this)"><?= tr('bs_tab_accounts') ?></button>
<button class="bs-tab-btn" onclick="switchBsTab('categories', this)"><?= tr('bs_tab_categories') ?></button>
<button class="bs-tab-btn" onclick="switchBsTab('rules', this)"><?= tr('bs_tab_rules') ?></button>
<button class="bs-tab-btn" onclick="switchBsTab('salaries', this)"><?= tr('bs_tab_salaries') ?></button>
<button class="bs-tab-btn" onclick="switchBsTab('csv', this)"><?= tr('bs_tab_csv') ?></button>
</div>
<div class="bs-content">
<div id="pane-accounts" class="bs-pane active">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.2rem; border-bottom: 1px solid var(--border-light); padding-bottom: 0.5rem;">
<h4 class="bs-section-title" style="margin: 0; border: none; padding: 0;"><?= tr('bs_tab_accounts') ?></h4>
<div style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; color: var(--text-muted); font-weight: 500;">
<span><?= tr('bs_currency_label') ?> :</span>
<select id="bs-currency-select" class="pf-input" style="width: 75px; padding: 3px 6px; font-size: 0.8rem; height: auto; cursor: pointer; background: var(--bg-page);" onchange="updateBudgetCurrency(this)">
<option value="€">EUR (€)</option>
<option value="$">USD ($)</option>
<option value="£">GBP (£)</option>
<option value="CHF">CHF</option>
</select>
</div>
</div>
<div id="bs-list-accounts">⏳ <?= tr('loading') ?></div>
<hr style="margin: 1.5rem 0; border:0; border-bottom:1px solid var(--border-light);">
<h5 class="bs-section-title" style="font-size:1rem;"><?= tr('bs_add_account_title') ?></h5>
<form id="form-add-account" style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<input type="text" name="name" class="pf-input" placeholder="<?= tr('bs_account_name_ph') ?>" required style="flex: 1; min-width: 150px; padding: 0.4rem 0.75rem;">
<select name="type" class="pf-input" style="width: 130px; padding: 0.4rem 0.75rem;">
<option value="checking"><?= tr('bs_type_checking') ?></option>
<option value="savings"><?= tr('bs_type_savings') ?></option>
</select>
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-categories" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_categories') ?></h4>
<div id="bs-list-categories">⏳ <?= tr('loading') ?></div>
<hr style="margin: 1.5rem 0; border:0; border-bottom:1px solid var(--border-light);">
<h5 class="bs-section-title" style="font-size:1rem;"><?= tr('bs_add_category_title') ?></h5>
<form id="form-add-category" style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<input type="text" name="code" class="pf-input" placeholder="<?= tr('bs_cat_code_ph') ?>" required style="width: 120px; padding: 0.4rem 0.75rem;">
<input type="text" name="label" class="pf-input" placeholder="<?= tr('bs_cat_label_ph') ?>" required style="flex: 1; min-width: 150px; padding: 0.4rem 0.75rem;">
<select name="type" class="pf-input" style="width: 120px; padding: 0.4rem 0.75rem;">
<option value="Expense"><?= tr('bs_type_expense') ?></option>
<option value="Income"><?= tr('bs_type_income') ?></option>
<option value="Savings"><?= tr('bs_type_savings_cat') ?></option>
</select>
<select name="icon" class="pf-input" style="width: 65px; padding: 0.4rem 0.2rem; font-size: 1.1rem; cursor: pointer; text-align: center;">
<option value="📌">📌</option>
<option value="🛒">🛒</option>
<option value="🥖">🥖</option>
<option value="🍽️">🍽️</option>
<option value="⛽">⛽</option>
<option value="🚗">🚗</option>
<option value="🚆">🚆</option>
<option value="🏠">🏠</option>
<option value="⚡">⚡</option>
<option value="💧">💧</option>
<option value="⚕️">⚕️</option>
<option value="🎒">🎒</option>
<option value="👕">👕</option>
<option value="📱">📱</option>
<option value="🎮">🎮</option>
<option value="✈️">✈️</option>
<option value="🎁">🎁</option>
<option value="🐶">🐶</option>
<option value="💵">💵</option>
<option value="🐷">🐷</option>
<option value="📈">📈</option>
</select>
<input type="color" name="color" value="#3b82f6" style="width: 38px; height: 38px; padding: 0; border: 1px solid var(--border-light); border-radius: 6px; cursor: pointer;">
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-rules" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_rules') ?></h4>
<input type="text" id="input-search-rules" class="bs-rule-search" placeholder="🔍 Rechercher un mot-clé (ex: AUCHAN)..." onkeyup="filterRules()">
<div id="bs-list-rules">⏳ <?= tr('loading') ?></div>
<hr style="margin: 1.5rem 0; border:0; border-bottom:1px solid var(--border-light);">
<h5 class="bs-section-title" style="font-size:1rem;"><?= tr('bs_add_rule_title') ?></h5>
<form id="form-add-rule" style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<input type="text" name="keyword" class="pf-input" placeholder="<?= tr('bs_rule_keyword_ph') ?>" required style="flex: 1; min-width: 150px; padding: 0.4rem 0.75rem;">
<select name="category" id="select-rule-category" class="pf-input" required style="width: 180px; padding: 0.4rem 0.75rem;">
</select>
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-salaries" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_salaries') ?> (<?= date('Y') ?>)</h4>
<div id="bs-list-salaries">⏳ <?= tr('loading') ?></div>
</div>
<div id="pane-csv" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_csv_title') ?></h4>
<p class="pf-muted-tiny" style="margin-top:0; margin-bottom:15px;"><?= tr('bs_csv_desc') ?></p>
<div id="csv-drop-zone" style="border: 2px dashed var(--border-light, #ccc); border-radius: var(--radius); padding: 30px; text-align: center; cursor: pointer; margin-bottom: 25px; transition: all 0.2s;">
<div style="font-size: 2rem; margin-bottom: 10px;">📥</div>
<h5 style="margin: 0 0 5px 0;"><?= tr('bs_csv_drop_title') ?></h5>
<p class="pf-muted-tiny" style="margin: 0;"><?= tr('bs_csv_drop_desc') ?></p>
<input type="file" id="csv_file_input" accept=".csv" style="display: none;">
</div>
<div id="csv-preview-container" style="display:none; background: var(--bg-soft, #f8fafc); padding: 15px; border-radius: var(--radius); border: 1px dashed var(--primary); margin-bottom: 25px;">
</div>
<h5 style="margin: 0 0 15px 0; border-bottom: 1px solid var(--border-light); padding-bottom: 5px;">
<?= tr('bs_csv_settings_title') ?>
</h5>
<form id="form-csv-mapping" onsubmit="saveCsvMapping(event)">
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
<div>
<label class="pf-label"><?= tr('bs_csv_delimiter') ?></label>
<select name="csv_delimiter" id="csv_delimiter" class="pf-input">
<option value=";"><?= tr('bs_csv_delim_semi') ?></option>
<option value=","><?= tr('bs_csv_delim_comma') ?></option>
<option value="\t"><?= tr('bs_csv_delim_tab') ?></option>
</select>
</div>
<div>
<label class="pf-label"><?= tr('bs_csv_date_format') ?></label>
<select name="csv_date_format" id="csv_date_format" class="pf-input">
<option value="d/m/Y">JJ/MM/AAAA</option>
<option value="Y-m-d">AAAA-MM-JJ</option>
</select>
</div>
</div>
<h5 style="margin: 15px 0 10px; font-size: 0.95rem;">
<?= tr('bs_csv_col_index') ?>
<small style="color:var(--text-muted); font-weight:normal;"><?= tr('bs_csv_col_index_help') ?></small>
</h5>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
<div>
<label class="pf-label"><?= tr('bs_csv_col_date') ?></label>
<input type="number" min="0" name="csv_col_date" id="csv_col_date" class="pf-input" value="0" required>
</div>
<div>
<label class="pf-label"><?= tr('bs_csv_col_label') ?></label>
<input type="number" min="0" name="csv_col_label" id="csv_col_label" class="pf-input" value="1" required>
</div>
</div>
<div style="background: var(--bg-panel); padding: 10px; border-radius: var(--radius); border: 1px solid var(--border-light); margin-bottom: 15px;">
<label class="pf-label"><?= tr('bs_csv_amount_mgmt') ?></label>
<select name="csv_amount_type" id="csv_amount_type" class="pf-input" style="margin-bottom: 10px;" onchange="toggleCsvAmountCols(this.value)">
<option value="single"><?= tr('bs_csv_amount_single') ?></option>
<option value="split"><?= tr('bs_csv_amount_split') ?></option>
</select>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div>
<label class="pf-label" id="lbl_col_debit"><?= tr('bs_csv_col_amount') ?></label>
<input type="number" min="0" name="csv_col_debit" id="csv_col_debit" class="pf-input" value="8" required>
</div>
<div id="wrapper_col_credit" style="display:none;">
<label class="pf-label"><?= tr('bs_csv_col_credit') ?></label>
<input type="number" min="0" name="csv_col_credit" id="csv_col_credit" class="pf-input" value="9">
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<label class="pf-label"><?= tr('bs_csv_col_ref') ?></label>
<input type="number" min="0" name="csv_col_ref" id="csv_col_ref" class="pf-input" value="3">
</div>
<div style="text-align: right;">
<button type="submit" class="btn btn-secondary">💾 <?= tr('btn_save_format') ?></button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
// --- AUTO-SCROLL DES ONGLETS MOBILES ---
document.addEventListener('DOMContentLoaded', () => {
const tabsContainer = document.querySelector('.budget-tabs-container');
const activeTab = document.querySelector('.tab-item.active');
if (tabsContainer && activeTab) {
if (tabsContainer.scrollWidth > tabsContainer.clientWidth) {
const scrollPos = activeTab.offsetLeft - (tabsContainer.offsetWidth / 2) + (activeTab.offsetWidth / 2);
tabsContainer.scrollTo({ left: scrollPos, behavior: 'smooth' });
}
}
});
// --- PARAMÈTRES BUDGET (MODALE & ONGLETS) ---
function openBudgetSettings() {
document.getElementById('modal-budget-settings').classList.add('show');
document.body.classList.add('no-scroll');
loadBudgetSettingsData();
}
function closeBudgetSettings() {
document.getElementById('modal-budget-settings').classList.remove('show');
document.body.classList.remove('no-scroll');
}
document.getElementById('modal-budget-settings')?.addEventListener('click', (e) => {
if (e.target.id === 'modal-budget-settings') closeBudgetSettings();
});
function switchBsTab(tabId, btnEl) {
document.querySelectorAll('.bs-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.bs-pane').forEach(p => p.classList.remove('active'));
btnEl.classList.add('active');
document.getElementById('pane-' + tabId).classList.add('active');
}
async function loadBudgetSettingsData() {
try {
const response = await pachaFetch('/modules/budget/includes/api/settings.php?action=get_all', { method: 'GET' });
if (!response.success) {
throw new Error(response.error || "<?= addslashes(tr('error_occured')) ?>");
}
renderAccounts(response.data.accounts);
renderCategories(response.data.categories);
populateRuleCategories(response.data.categories);
renderRules(response.data.rules);
renderSalaries(response.data.salaries, response.data.year);
const currencySelect = document.getElementById('bs-currency-select');
if (currencySelect && response.data.currency) {
currencySelect.value = response.data.currency;
}
} catch (err) {
console.error("Erreur chargement paramètres :", err);
const errorMsg = `<div class="pf-alert pf-alert--error" style="margin-top:10px;">❌ Erreur : ${err.message}</div>`;
['accounts', 'categories', 'rules', 'salaries'].forEach(id => {
const el = document.getElementById('bs-list-' + id);
if (el) el.innerHTML = errorMsg;
});
}
}
// ==========================================================================
// --- RENDU DES ONGLETS (PARAMÈTRES BUDGET) ---
// ==========================================================================
// 1. ONGLET : COMPTES BANCAIRES
function renderAccounts(accounts) {
const container = document.getElementById('bs-list-accounts');
if (!container) return;
if (!accounts || accounts.length === 0) {
container.innerHTML = '<em style="color:var(--text-muted)"><?= addslashes(tr("bs_empty_accounts")) ?></em>';
return;
}
let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem;">';
accounts.forEach(acc => {
const typeBadge = acc.account_type === 'checking' ? '<?= addslashes(tr("bs_type_checking")) ?>' : '<?= addslashes(tr("bs_type_savings")) ?>';
const defaultBadge = acc.is_default == 1 ? '<span style="font-size:0.7em; background:var(--success); color:white; padding:2px 6px; border-radius:4px; margin-left:8px;"><?= addslashes(tr("bs_badge_default")) ?></span>' : '';
const ownerText = acc.owner_name ? `<?= addslashes(tr("bs_owner")) ?> : ${acc.owner_name}` : '<?= addslashes(tr("bs_shared_account")) ?>';
html += `
<div class="bs-list-item" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: var(--bg-panel); border-radius: var(--radius); border: 1px solid var(--border-light);">
<div>
<strong>${acc.name}</strong> ${defaultBadge}<br>
<small style="color:var(--text-muted);">${typeBadge} · ${ownerText}</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action delete" onclick="deleteAccount(${acc.id})" title="<?= addslashes(tr('btn_delete') ?? 'Supprimer') ?>">🗑️</button>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
// 2. ONGLET : CATÉGORIES (Affichage à plat, sans accordéon)
function renderCategories(categories) {
const container = document.getElementById('bs-list-categories');
if (!container) return;
if (!categories || categories.length === 0) {
container.innerHTML = '<em style="color:var(--text-muted)"><?= addslashes(tr("bs_empty_categories")) ?></em>';
return;
}
let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem;">';
categories.forEach(cat => {
let typeLabel = '';
if (cat.type === 'Income') typeLabel = '<?= addslashes(tr("bs_type_income")) ?>';
else if (cat.type === 'Expense') typeLabel = '<?= addslashes(tr("bs_type_expense")) ?>';
else typeLabel = '<?= addslashes(tr("bs_type_savings_cat")) ?>';
html += `
<div class="bs-list-item" style="border-left: 4px solid ${cat.color || '#ccc'}; display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: var(--bg-panel); border-radius: var(--radius); border: 1px solid var(--border-light); border-left-width: 4px;">
<div>
<span style="font-size:1.2rem; margin-right:8px;">${cat.icon || '📌'}</span>
<strong style="color: var(--text-main); font-size: 0.95rem;">${cat.label}</strong> <small style="color:var(--text-muted)">(${cat.code} — ${typeLabel})</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action delete" onclick="deleteCategory(${cat.id})" title="<?= addslashes(tr('btn_delete') ?? 'Supprimer') ?>">🗑️</button>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
// 3. ONGLET : RÈGLES D'IMPORT (Affichage en accordéons par catégorie)
function renderRules(rules) {
const container = document.getElementById('bs-list-rules');
if (!container) return;
if (!rules || rules.length === 0) {
container.innerHTML = '<em style="color:var(--text-muted)"><?= addslashes(tr("bs_empty_rules")) ?></em>';
return;
}
// Regroupement des règles par catégorie budgétaire
const groupedRules = {};
rules.forEach(r => {
const catName = r.cat_label || r.category;
if (!groupedRules[catName]) groupedRules[catName] = [];
groupedRules[catName].push(r);
});
let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem;">';
for (const [catName, catRules] of Object.entries(groupedRules)) {
html += `
<details class="pf-accordion js-rule-group" data-catname="${catName.toLowerCase()}">
<summary class="pf-accordion-summary" style="display: flex; justify-content: space-between; align-items: center; cursor: pointer;">
<span>📁 ${catName}</span>
<span style="font-size: 0.8rem; background: var(--border-light); padding: 2px 8px; border-radius: 10px; color: var(--text-muted); font-weight: normal; margin-left: auto; margin-right: 10px;">
${catRules.length}
</span>
</summary>
<div class="pf-accordion-content" style="padding: 0.5rem;">
<div class="bs-rule-tags" style="display: flex; flex-wrap: wrap; gap: 0.4rem;">
`;
catRules.forEach(r => {
html += `
<div class="bs-rule-tag js-rule-tag" data-keyword="${r.keyword.toLowerCase()}">
${r.keyword}
<button type="button" class="bs-rule-tag-del" onclick="deleteRule(${r.id})" title="<?= addslashes(tr('btn_delete') ?? 'Supprimer') ?>">×</button>
</div>
`;
});
html += `
</div>
</div>
</details>
`;
}
html += '</div>';
container.innerHTML = html;
}
// 4. ACTION : FILTRAGE AVANCÉ DES RÈGLES (Recherche intelligente avec auto-ouverture)
function filterRules() {
const searchVal = document.getElementById('input-search-rules').value.toLowerCase();
const groups = document.querySelectorAll('.js-rule-group');
groups.forEach(group => {
let hasVisibleTag = false;
const tags = group.querySelectorAll('.js-rule-tag');
const catName = group.getAttribute('data-catname');
tags.forEach(tag => {
const keyword = tag.getAttribute('data-keyword');
if (keyword.includes(searchVal)) {
tag.style.display = 'inline-flex';
hasVisibleTag = true;
} else {
tag.style.display = 'none';
}
});
// Si le nom de la catégorie principale correspond à la recherche, on affiche tout le groupe
if (catName.includes(searchVal) && searchVal.length > 0) {
tags.forEach(tag => tag.style.display = 'inline-flex');
hasVisibleTag = true;
}
if (hasVisibleTag) {
group.style.display = 'block';
// Ouvre l'accordéon automatiquement pendant la saisie d'une recherche
if (searchVal.length > 0) {
group.setAttribute('open', '');
}
} else {
group.style.display = 'none';
group.removeAttribute('open');
}
});
}
// 5. ONGLET : SALAIRES
function renderSalaries(salaries, year) {
const container = document.getElementById('bs-list-salaries');
if (!container) return;
if (!salaries || salaries.length === 0) {
container.innerHTML = `<em style="color:var(--text-muted)"><?= addslashes(tr("bs_empty_salaries")) ?> ${year}.</em>`;
return;
}
let html = `<p class="pf-muted-tiny" style="margin-top:0; margin-bottom:10px;"><?= addslashes(tr("bs_salaries_year_desc")) ?> <strong>${year}</strong>.</p>`;
const currency = '<?= CURRENCY ?>';
html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
salaries.forEach(s => {
html += `
<div class="bs-list-item" id="salary-item-${s.id}" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: var(--bg-panel); border-radius: var(--radius); border: 1px solid var(--border-light);">
<div>
<strong>${s.person}</strong><br>
<small style="color:var(--text-muted);">
<?= addslashes(tr("bs_salary_net")) ?> : ${s.salary} ${currency}<br>
<?= addslashes(tr("bs_salary_fees")) ?> : ${s.mensualite} ${currency}
</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action edit"
data-id="${s.id}"
data-person="${s.person}"
data-salary="${s.salary}"
data-mensualite="${s.mensualite}"
onclick="inlineEditSalary(this)"
title="<?= addslashes(tr('btn_edit') ?? 'Modifier') ?>">✏️</button>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
// --- CRUD ACTIONS ---
async function deleteAccount(id) {
if (!confirm('<?= addslashes(tr("bs_confirm_delete")) ?>')) return;
try {
const formData = new FormData();
formData.append('action', 'delete_account');
formData.append('id', id);
const response = await pachaFetch('/modules/budget/includes/api/settings.php', {
method: 'POST',
body: formData
});
if (!response.success) throw new Error(response.error || '<?= addslashes(tr("error_occured")) ?>');
await loadBudgetSettingsData();
} catch (err) {
alert("Erreur : " + err.message);
}
}
const formAddAccount = document.getElementById('form-add-account');
if (formAddAccount) {
formAddAccount.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = formAddAccount.querySelector('button[type="submit"]');
const oldText = btn.innerText;
btn.innerText = '⏳';
btn.disabled = true;
try {
const formData = new FormData(formAddAccount);
formData.append('action', 'add_account');
const response = await pachaFetch('/modules/budget/includes/api/settings.php', {
method: 'POST',
body: formData
});
if (!response.success) throw new Error(response.error || '<?= addslashes(tr("error_occured")) ?>');
formAddAccount.reset();
await loadBudgetSettingsData();
} catch (err) {
alert("Erreur : " + err.message);
} finally {
btn.innerText = oldText;
btn.disabled = false;
}
});
}
// --- CRUD CATÉGORIES ---
async function deleteCategory(id) {
if (!confirm('<?= addslashes(tr("bs_confirm_delete_cat")) ?>')) return;
try {
const formData = new FormData();
formData.append('action', 'delete_category');
formData.append('id', id);
const response = await pachaFetch('/modules/budget/includes/api/settings.php', {
method: 'POST',
body: formData
});
if (!response.success) throw new Error(response.error || '<?= addslashes(tr("error_occured")) ?>');
await loadBudgetSettingsData();
} catch (err) {
alert("Erreur : " + err.message);
}
}
const formAddCategory = document.getElementById('form-add-category');
if (formAddCategory) {
formAddCategory.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = formAddCategory.querySelector('button[type="submit"]');
const oldText = btn.innerText;
btn.innerText = '⏳';
btn.disabled = true;
try {
const formData = new FormData(formAddCategory);
formData.append('action', 'add_category');
const response = await pachaFetch('/modules/budget/includes/api/settings.php', {
method: 'POST',
body: formData
});
if (!response.success) throw new Error(response.error || '<?= addslashes(tr("error_occured")) ?>');
formAddCategory.reset();
// On remet une couleur par défaut propre après reset
formAddCategory.querySelector('input[type="color"]').value = "#3b82f6";
await loadBudgetSettingsData();
} catch (err) {
alert("Erreur : " + err.message);
} finally {
btn.innerText = oldText;
btn.disabled = false;
}
});
}
// --- CRUD RÈGLES D'IMPORT ---
// Remplir le menu déroulant des catégories dans le formulaire d'ajout
function populateRuleCategories(categories) {
const select = document.getElementById('select-rule-category');
if (!select) return;
select.innerHTML = ''; // On vide
if (!categories || categories.length === 0) return;
categories.forEach(cat => {
const opt = document.createElement('option');
opt.value = cat.code;
opt.textContent = `${cat.icon || ''} ${cat.label}`;
select.appendChild(opt);
});
}
// Supprimer une règle
async function deleteRule(id) {
if (!confirm('<?= addslashes(tr("bs_confirm_delete_rule")) ?>')) return;
try {
const formData = new FormData();
formData.append('action', 'delete_rule');
formData.append('id', id);
const response = await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: formData });
if (!response.success) throw new Error(response.error || '<?= addslashes(tr("error_occured")) ?>');
await loadBudgetSettingsData();
} catch (err) {
alert("Erreur : " + err.message);
}
}
// Ajouter une règle
const formAddRule = document.getElementById('form-add-rule');
if (formAddRule) {
formAddRule.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = formAddRule.querySelector('button[type="submit"]');
const oldText = btn.innerText;
btn.innerText = '⏳';
btn.disabled = true;
try {
const formData = new FormData(formAddRule);
formData.append('action', 'add_rule');
const response = await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: formData });
if (!response.success) throw new Error(response.error || '<?= addslashes(tr("error_occured")) ?>');
formAddRule.reset();
await loadBudgetSettingsData();
} catch (err) {
alert("Erreur : " + err.message);
} finally {
btn.innerText = oldText;
btn.disabled = false;
}
});
}
// --- CRUD SALAIRES (EDITION INLINE) ---
// Active le mode édition sur la ligne sélectionnée
function inlineEditSalary(btnEl) {
const id = btnEl.dataset.id;
const person = btnEl.dataset.person;
const salary = btnEl.dataset.salary;
const mensualite = btnEl.dataset.mensualite;
const currency = '<?= CURRENCY ?>';
const rowEl = document.getElementById(`salary-item-${id}`);
if (!rowEl) return;
// Remplacement temporaire du contenu de la ligne par un formulaire compact
rowEl.innerHTML = `
<form onsubmit="submitInlineSalary(event, ${id})" style="display: flex; width: 100%; gap: 10px; align-items: center; flex-wrap: wrap;">
<div style="flex: 1; min-width: 100px;">
<strong style="color: var(--text-main); font-size: 0.95rem;">${person}</strong>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<small style="color: var(--text-muted); font-size: 0.8rem; margin-right: 2px;"><?= addslashes(tr("bs_salary_net")) ?>:</small>
<input type="number" step="0.01" name="salary" class="pf-input" value="${salary}" required style="width: 85px; padding: 4px 8px;"> <span style="font-size: 0.85rem;">${currency}</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<small style="color: var(--text-muted); font-size: 0.8rem; margin-right: 2px;"><?= addslashes(tr("bs_salary_fees")) ?>:</small>
<input type="number" step="0.01" name="mensualite" class="pf-input" value="${mensualite}" required style="width: 85px; padding: 4px 8px;"> <span style="font-size: 0.85rem;">${currency}</span>
</div>
<div style="display: flex; gap: 4px; margin-left: auto;">
<button type="submit" class="btn btn-secondary" style="padding: 4px 10px;" title="<?= addslashes(tr('btn_save') ?? 'Enregistrer') ?>">💾</button>
<button type="button" class="btn btn-ghost" onclick="loadBudgetSettingsData()" style="padding: 4px 10px;" title="<?= addslashes(tr('btn_cancel') ?? 'Annuler') ?>">❌</button>
</div>
</form>
`;
}
// Soumission du formulaire d'édition à l'API
async function submitInlineSalary(event, id) {
event.preventDefault();
const form = event.target;
const btn = form.querySelector('button[type="submit"]');
btn.innerText = '⏳';
btn.disabled = true;
try {
const formData = new FormData(form);
formData.append('action', 'save_salary');
formData.append('id', id);
const response = await pachaFetch('/modules/budget/includes/api/settings.php', {
method: 'POST',
body: formData
});
if (!response.success) throw new Error(response.error || '<?= addslashes(tr("error_occured")) ?>');
await loadBudgetSettingsData(); // Recharge les données fraîches à plat
} catch (err) {
alert("Erreur : " + err.message);
btn.innerText = '💾';
btn.disabled = false;
}
}
// Sauvegarde de la devise du foyer en temps réel
async function updateBudgetCurrency(selectEl) {
try {
const formData = new FormData();
formData.append('action', 'save_currency');
formData.append('currency', selectEl.value);
const response = await pachaFetch('/modules/budget/includes/api/settings.php', {
method: 'POST',
body: formData
});
if (!response.success) throw new Error(response.error || '<?= addslashes(tr("error_occured")) ?>');
// Si ton application possède un système de notification Toast global
if (typeof showToast === 'function') {
showToast('<?= addslashes(tr("bs_currency_updated")) ?> ✅');
}
// On rafraîchit les listes pour mettre à jour l'affichage des symboles (ex: dans l'onglet salaires)
await loadBudgetSettingsData();
} catch (err) {
alert("Erreur : " + err.message);
}
}
// --- GESTION DU MAPPING CSV ---
window.I18N = {
...(window.I18N || {}),
'bs_csv_col_amount': <?= json_encode(tr('bs_csv_col_amount')) ?>,
'bs_csv_col_debit': <?= json_encode(tr('bs_csv_col_debit')) ?>,
'bs_csv_saved': <?= json_encode(tr('bs_csv_saved')) ?>,
'error_occured': <?= json_encode(tr('error_occured')) ?>
};
function toggleCsvAmountCols(type) {
if (type === 'split') {
document.getElementById('wrapper_col_credit').style.display = 'block';
document.getElementById('lbl_col_debit').innerText = window.I18N['bs_csv_col_debit'];
} else {
document.getElementById('wrapper_col_credit').style.display = 'none';
document.getElementById('lbl_col_debit').innerText = window.I18N['bs_csv_col_amount'];
}
}
function renderCsvMapping(mapping) {
if (!mapping) return;
document.getElementById('csv_delimiter').value = mapping.delimiter || ';';
document.getElementById('csv_date_format').value = mapping.date_format || 'd/m/Y';
document.getElementById('csv_col_date').value = mapping.col_date ?? 0;
document.getElementById('csv_col_label').value = mapping.col_label ?? 1;
document.getElementById('csv_col_ref').value = mapping.col_ref ?? 3;
const amountType = mapping.amount_type || 'split';
document.getElementById('csv_amount_type').value = amountType;
document.getElementById('csv_col_debit').value = mapping.col_debit ?? 8;
document.getElementById('csv_col_credit').value = mapping.col_credit ?? 9;
toggleCsvAmountCols(amountType);
}
async function saveCsvMapping(e) {
e.preventDefault();
const form = e.target;
const btn = form.querySelector('button[type="submit"]');
const oldText = btn.innerText;
btn.innerText = '⏳...';
btn.disabled = true;
try {
const formData = new FormData(form);
formData.append('action', 'save_csv_mapping');
const response = await pachaFetch('/modules/budget/includes/api/settings.php', {
method: 'POST',
body: formData
});
if (!response.success) throw new Error(response.error || window.I18N['error_occured']);
if (typeof showToast === 'function') showToast(window.I18N['bs_csv_saved']);
else alert(window.I18N['bs_csv_saved']);
} catch (err) {
alert(window.I18N['error_occured'] + " : " + err.message);
} finally {
btn.innerText = oldText;
btn.disabled = false;
}
}
// --- GESTION DE LA DROP ZONE CSV ET FILEREADER ---
// Ajout des nouvelles clés I18N magiques
window.I18N = {
...(window.I18N || {}),
'bs_csv_err_type': <?= json_encode(tr('bs_csv_err_type')) ?>,
'bs_csv_file_ok': <?= json_encode(tr('bs_csv_file_ok')) ?>,
'bs_csv_preview_title': <?= json_encode(tr('bs_csv_preview_title')) ?>,
'bs_csv_empty_col': <?= json_encode(tr('bs_csv_empty_col')) ?>,
'bs_csv_no_data': <?= json_encode(tr('bs_csv_no_data')) ?>
};
const dropZone = document.getElementById('csv-drop-zone');
const fileInput = document.getElementById('csv_file_input');
if (dropZone && fileInput) {
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.backgroundColor = 'rgba(0, 123, 255, 0.05)';
dropZone.style.borderColor = 'var(--primary-color, #007bff)';
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.style.backgroundColor = '';
dropZone.style.borderColor = 'var(--border-light, #ccc)';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.backgroundColor = '';
dropZone.style.borderColor = 'var(--border-light, #ccc)';
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
handleCsvUpload(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', function() {
if (this.files.length) {
handleCsvUpload(this.files[0]);
}
});
}
function handleCsvUpload(file) {
if (!file.name.endsWith('.csv')) {
alert(window.I18N['bs_csv_err_type']);
return;
}
if (typeof showToast === 'function') {
showToast(window.I18N['bs_csv_file_ok'] + ' : ' + file.name);
}
const reader = new FileReader();
reader.onload = function(e) {
const text = e.target.result;
const delimiter = document.getElementById('csv_delimiter').value || ';';
const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
if (lines.length > 0) {
// Extraction des en-têtes (Ligne 0)
const headers = lines[0].split(delimiter).map(h => h.replace(/^"|"$/g, '').trim());
// Extraction de max 2 lignes de données (Ligne 1 et 2)
const sampleRows = [];
for (let i = 1; i < Math.min(lines.length, 3); i++) {
const rowData = lines[i].split(delimiter).map(d => d.replace(/^"|"$/g, '').trim());
sampleRows.push(rowData);
}
renderCsvTablePreview(headers, sampleRows);
}
};
reader.readAsText(file, 'ISO-8859-1');
}
function renderCsvTablePreview(headers, sampleRows) {
const container = document.getElementById('csv-preview-container');
let html = `<h5 style="margin: 0 0 10px 0; color: var(--primary); font-size: 0.95rem;">👁️ ${window.I18N['bs_csv_preview_title']}</h5>`;
// Conteneur avec scroll horizontal si le tableau est trop large
html += `<div style="overflow-x: auto; max-width: 100%; border: 1px solid var(--border-light); border-radius: 8px; background: white;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem; text-align: left; white-space: nowrap;">
<thead style="background: var(--bg-page, #f1f5f9); border-bottom: 2px solid var(--border-light);">
<tr>`;
// Rendu des en-têtes de colonnes (avec le numéro)
headers.forEach((header, index) => {
const cleanHeader = header || `(${window.I18N['bs_csv_empty_col']})`;
html += `<th style="padding: 10px 15px; font-weight: 600; color: var(--text-main); border-right: 1px solid var(--border-light);">
<div style="font-size: 0.75rem; color: var(--primary); margin-bottom: 2px;">N° ${index}</div>
<div>${cleanHeader}</div>
</th>`;
});
html += ` </tr>
</thead>
<tbody>`;
// Rendu des données
if (sampleRows.length === 0) {
html += `<tr><td colspan="${headers.length}" style="padding: 15px; text-align: center; color: var(--text-muted); font-style: italic;">${window.I18N['bs_csv_no_data']}</td></tr>`;
} else {
sampleRows.forEach(row => {
html += `<tr style="border-bottom: 1px solid #e2e8f0;">`;
// On boucle sur la taille des headers pour s'assurer que l'affichage ne casse pas si la ligne de données est incomplète
for (let i = 0; i < headers.length; i++) {
const cellData = row[i] !== undefined ? row[i] : '';
// Raccourcir la donnée si elle est vraiment très longue (ex: libellé de 100 caractères)
const displayData = cellData.length > 40 ? cellData.substring(0, 40) + '...' : cellData;
html += `<td style="padding: 10px 15px; color: var(--text-muted); border-right: 1px dashed #e2e8f0;">${displayData}</td>`;
}
html += `</tr>`;
});
}
html += `</tbody></table></div>`;
container.innerHTML = html;
container.style.display = 'block';
}
</script>
<?php require __DIR__ . '/footer.php'; ?>
<?php
require __DIR__ . '/modules/budget/views/settings_modal.php';
require __DIR__ . '/footer.php';
?>
+77 -18
View File
@@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS pf_foyer_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT '',
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
csv_mapping TEXT DEFAULT NULL,
cares_modes TEXT DEFAULT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -44,12 +46,14 @@ CREATE TABLE IF NOT EXISTS pf_users (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Personnes ────────────────────────────────────────────────────────────────
CREATE TABLE `pf_people` (
CREATE TABLE IF NOT EXISTS `pf_people` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`user_id` int(11) DEFAULT NULL,
`role` varchar(50) DEFAULT NULL,
`care_modes` varchar(255) DEFAULT NULL,
`color` varchar(7) DEFAULT '#0891b2',
`is_active` TINYINT(1) DEFAULT 1,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -88,10 +92,29 @@ CREATE TABLE IF NOT EXISTS pf_leave_snapshots (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_person_leave_meta (
id INT AUTO_INCREMENT PRIMARY KEY,
person_id INT NOT NULL UNIQUE,
anniversary_date DATE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
id INT(11) AUTO_INCREMENT PRIMARY KEY,
person_id INT(11) NOT NULL,
leave_type VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL,
method VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'FIXED',
allowance DECIMAL(5,2) DEFAULT 0.00,
anniversary_date DATE DEFAULT NULL,
INDEX (person_id),
INDEX (leave_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ─── Types de Congés (Référentiel) ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_leave_types (
id INT(11) AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) COLLATE utf8mb4_general_ci NOT NULL,
label VARCHAR(100) COLLATE utf8mb4_general_ci NOT NULL,
default_allowance DECIMAL(5,2) DEFAULT 0.00,
reset_month INT(11) DEFAULT 1,
allow_carry_over TINYINT(1) DEFAULT 0,
UNIQUE KEY code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT IGNORE INTO pf_leave_types (code, label, default_allowance, reset_month, allow_carry_over) VALUES
('CP', 'Congés Payés', 25.00, 6, 0);
CREATE TABLE IF NOT EXISTS pf_calendar_weeks (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -108,6 +131,19 @@ CREATE TABLE IF NOT EXISTS pf_calendar_weeks (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Budget ───────────────────────────────────────────────────────────────────
-- NOUVEAU : Table des comptes bancaires
CREATE TABLE IF NOT EXISTS pf_bank_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
account_type ENUM('checking', 'savings') DEFAULT 'checking',
owner_person_id INT DEFAULT NULL,
is_default TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (owner_person_id) REFERENCES pf_people(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_budget_items (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
@@ -132,7 +168,8 @@ CREATE TABLE IF NOT EXISTS pf_expenses (
amount DECIMAL(10,2) DEFAULT 0,
import_ref VARCHAR(255) DEFAULT NULL,
budget_item_id INT DEFAULT NULL,
holiday_id INT DEFAULT NULL
holiday_id INT DEFAULT NULL,
salary_id INT DEFAULT NULL -- 🔥 AJOUT : Lien vers le salaire configuré
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_alloc_categories (
@@ -160,7 +197,8 @@ CREATE TABLE IF NOT EXISTS pf_savings (
month_date VARCHAR(10) NOT NULL,
category VARCHAR(100) NOT NULL,
amount DECIMAL(10,2) DEFAULT 0,
holiday_id INT DEFAULT NULL
holiday_id INT DEFAULT NULL,
UNIQUE KEY uq_savings (owner, month_date, category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_bank_snapshots (
@@ -188,7 +226,6 @@ CREATE TABLE IF NOT EXISTS pf_import_rules (
budget_item_id INT(11) NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `pf_advances` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`advance_date` date NOT NULL,
@@ -214,11 +251,7 @@ CREATE TABLE IF NOT EXISTS pf_budget_categories (
-- Insertion des catégories de base pour toute nouvelle famille
INSERT IGNORE INTO pf_budget_categories (code, label, type, color, icon) VALUES
('INCOME', 'Revenus', 'Income', '#10b981', '💵'),
('FMCG', 'Alimentation & Courses', 'Expense', '#3b82f6', '🛒'),
('FUEL', 'Carburant', 'Expense', '#f59e0b', ''),
('FIXED', 'Charges Fixes', 'Expense', '#ef4444', '🏢'),
('SCHOOL', 'École & Garde', 'Expense', '#a855f7', '🎒'),
('SAVINGS', 'Épargne', 'Expense', '#8b5cf6', '🐷');
('FIXED', 'Charges Fixes', 'Expense', '#ef4444', '🏢');
-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_notes (
@@ -308,16 +341,42 @@ CREATE TABLE IF NOT EXISTS pf_geocode_cache (
-- ─── Cadeaux ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_gifts (
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
id INT(11) AUTO_INCREMENT PRIMARY KEY,
year INT(11) NOT NULL,
adult_name VARCHAR(100) NOT NULL,
payer_name VARCHAR(100),
payer_name VARCHAR(100) DEFAULT NULL,
child_name VARCHAR(100) NOT NULL,
occasion VARCHAR(50) NOT NULL,
gift_description TEXT NOT NULL,
product_link VARCHAR(500) DEFAULT NULL,
amount DECIMAL(8,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
amount DECIMAL(8,2) DEFAULT 0.00
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS pf_gift_occasions (
id INT(11) AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
icon VARCHAR(20) DEFAULT '🎁',
month_date VARCHAR(5) DEFAULT NULL,
is_active TINYINT(1) DEFAULT 1,
sort_order INT DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT IGNORE INTO pf_gift_occasions (code, name, icon, month_date, is_active, sort_order) VALUES
('XMAS', 'Noël', '🎄', '12-25', 1, 1),
('BIRTHDAY', 'Anniversaire', '🎂', NULL, 1, 2),
('OTHER', 'Autre', '🎁', NULL, 1, 99);
-- Table des règles (Qui offre à Qui)
CREATE TABLE IF NOT EXISTS pf_gift_rules (
id INT(11) AUTO_INCREMENT PRIMARY KEY,
adult_person_id INT(11) NOT NULL,
child_person_id INT(11) NOT NULL,
occasion_id INT(11) NOT NULL,
INDEX (adult_person_id),
INDEX (child_person_id),
INDEX (occasion_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ─── Garage Manager ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_vehicles (
+120 -15
View File
@@ -29,7 +29,7 @@ foreach ($familyPeople as $p) {
}
// 2. Modes de garde (globaux du foyer, pour synchroniser avec le JS)
$stmtFoyer = $pdo->query("SELECT care_modes FROM pf_foyer_settings LIMIT 1");
$stmtFoyer = $pdo->query("SELECT care_modes, zone_scolaire FROM pf_foyer_settings LIMIT 1");
$foyerData = $stmtFoyer->fetch(PDO::FETCH_ASSOC);
$activeCareModes = json_decode($foyerData['care_modes'] ?? '[]', true);
if (!is_array($activeCareModes)) $activeCareModes = [];
@@ -45,6 +45,38 @@ foreach ($dbLeaves as $l) {
}
$allLeaveTypes = array_keys($allLeaveTypes);
// ==========================================
// VÉRIFICATIONS D'INTÉGRITÉ (ALERTES)
// ==========================================
$calendarAlerts = [];
// 1. Aucun membre adulte/parent configuré
if (empty($parents)) {
$calendarAlerts[] = "<strong>Aucun adulte configuré.</strong> Rendez-vous dans les Paramètres Généraux de HouseHub pour ajouter les membres du foyer.";
}
// 2. Enfants présents, mais aucun mode de garde global défini
if (!empty($kids) && empty($activeCareModes)) {
$calendarAlerts[] = "<strong>Modes de garde manquants.</strong> Vous avez des enfants, mais aucun mode de garde n'est défini. Cliquez sur l'icône ⚙️ (Paramètres du calendrier) pour les ajouter.";
}
// 3. Référentiel des types de congés vide
try {
$countLeaveTypes = $pdo->query("SELECT COUNT(*) FROM pf_leave_types")->fetchColumn();
if ($countLeaveTypes == 0) {
$calendarAlerts[] = "<strong>Types de congés manquants.</strong> Le catalogue des congés (CP, RTT...) est vide. Ajoutez-les dans les paramètres généraux.";
}
} catch (Exception $e) {
// Si la table n'existe pas encore
$calendarAlerts[] = "<strong>Table des congés manquante.</strong> Veuillez mettre à jour la base de données.";
}
// 4. Zone scolaire manquante (si tu l'as stockée dans pf_foyer_settings)
$zoneScolaire = $foyerData['zone_scolaire'] ?? null;
if (empty($zoneScolaire)) {
$calendarAlerts[] = "<strong>Zone scolaire non définie.</strong> Cliquez sur l'icône ⚙️ pour configurer votre zone afin d'afficher les vacances scolaires.";
}
$pageTitle = tr('fc_page_title');
$activePage = "family-calendar";
$mainClass = "pf-family-calendar";
@@ -77,6 +109,20 @@ require __DIR__ . '/header.php';
</div>
</div>
<?php if (!empty($calendarAlerts)): ?>
<div class="pf-alert pf-alert-warning" style="background: var(--bg-warning, #fffbeb); border: 1px solid var(--border-warning, #fcd34d); color: var(--text-warning, #92400e); padding: 16px; border-radius: 8px; margin-bottom: 24px; display: flex; gap: 12px; align-items: flex-start;">
<div style="font-size: 1.5rem; line-height: 1;">⚠️</div>
<div>
<h4 style="margin: 0 0 8px 0; font-size: 1.05rem;">Configuration incomplète</h4>
<ul style="margin: 0; padding-left: 18px; font-size: 0.95rem; line-height: 1.5;">
<?php foreach ($calendarAlerts as $alert): ?>
<li style="margin-bottom: 4px;"><?= $alert ?></li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php endif; ?>
<div id="modalHolidays" class="pf-modal">
<div class="pf-modal-content">
<div class="pf-modal-header">
@@ -155,58 +201,117 @@ require __DIR__ . '/header.php';
</div>
<div id="modalCalendarSettings" class="pf-modal">
<div class="pf-modal-content" style="max-width: 600px; width: 90%;">
<div class="pf-modal-content" style="max-width: 650px; width: 95%;">
<div class="pf-modal-header">
<h3 class="pf-modal-title">⚙️ <?= tr('fc_modal_settings_title') ?></h3>
<button type="button" class="pf-modal-close" onclick="closeCalendarSettings()">&times;</button>
</div>
<div style="display: flex; gap: 4px; background: var(--bg-page); padding: 4px; border-radius: 8px; margin: 1rem;">
<button type="button" id="tab-btn-foyer" class="bs-tab-btn active" style="flex: 1; padding: 8px 12px; font-weight: 600;" onclick="switchCalendarTab('foyer')">🏡 Foyer</button>
<button type="button" id="tab-btn-membres" class="bs-tab-btn" style="flex: 1; padding: 8px 12px; font-weight: 600;" onclick="switchCalendarTab('membres')">👥 Membres & Droits</button>
<button type="button" id="tab-btn-foyer" class="bs-tab-btn active" style="flex: 1; padding: 8px 12px; font-weight: 600;" onclick="switchCalendarTab('foyer')">
🏡 <?= tr('fc_tab_foyer') ?>
</button>
<button type="button" id="tab-btn-membres" class="bs-tab-btn" style="flex: 1; padding: 8px 12px; font-weight: 600;" onclick="switchCalendarTab('membres')">
👥 <?= tr('fc_tab_members') ?>
</button>
</div>
<div class="pf-modal-body" style="max-height: 60vh; overflow-y: auto; padding-top: 0;">
<div class="pf-modal-body" style="max-height: 65vh; overflow-y: auto; padding-top: 0; padding-bottom: 1rem;">
<div id="cal-pane-foyer" class="cal-settings-pane">
<form id="formCalFoyer" onsubmit="submitCalFoyer(event)">
<div class="pf-form-group">
<label class="pf-label"><?= tr('fc_zone_label') ?></label>
<select id="setZoneScolaire" name="zone_scolaire" class="pf-input" required style="width: 100%;">
<option value="A">Zone A</option>
<option value="B">Zone B</option>
<option value="C">Zone C</option>
<option value="Autre">Autre / Hors France</option>
<option value="A"><?= tr('fc_zone_a') ?></option>
<option value="B"><?= tr('fc_zone_b') ?></option>
<option value="C"><?= tr('fc_zone_c') ?></option>
<option value="Autre"><?= tr('fc_zone_other') ?></option>
</select>
<small class="pf-muted-note"><?= tr('fc_zone_desc') ?></small>
</div>
<div class="pf-form-group" style="margin-top: 1.5rem;">
<label class="pf-label"><?= tr('fc_care_modes_label') ?></label>
<?php if (!empty($kids)): ?>
<div id="careModesContainer" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; min-height: 38px; padding: 8px; background: var(--bg-page); border-radius: var(--radius); border: 1px solid var(--border-light);"></div>
<div style="display: flex; gap: 8px;">
<input type="text" id="inputNewCareMode" class="pf-input" placeholder="<?= tr('fc_add_mode_placeholder') ?>" style="flex: 1;">
<button type="button" class="btn btn-secondary" style="padding: 0 16px; border-radius: 6px;" onclick="addCareModeTag()"></button>
<button type="button" class="pf-btn btn-secondary" style="padding: 0 16px; border-radius: 6px;" onclick="addCareModeTag()"></button>
</div>
<small class="pf-muted-note"><?= tr('fc_care_modes_desc') ?></small>
<?php else: ?>
<div style="background: var(--bg-subtle); padding: 12px; border-radius: 8px; border: 1px dashed var(--border-main); text-align: center;">
<p style="margin: 0 0 10px 0; color: var(--text-muted); font-size: 0.9rem;">
<?= tr('fc_empty_kids_msg') ?>
</p>
<a href="/settings.php" class="pf-btn btn-secondary" style="text-decoration: none; display: inline-block; font-size: 0.85rem;">
⚙️ <?= tr('fc_link_settings') ?>
</a>
</div>
<?php endif; ?>
</div>
<div style="margin-top: 2rem; display: flex; justify-content: flex-end; gap: 8px;">
<button type="button" class="pf-btn pf-btn-secondary" onclick="closeCalendarSettings()"><?= tr('btn_cancel') ?></button>
<?php if (!empty($kids)): ?>
<div style="margin-top: 1rem; display: flex; justify-content: flex-end;">
<button type="submit" class="pf-btn pf-btn-primary"><?= tr('save') ?></button>
</div>
<?php endif; ?>
</form>
<div class="pf-form-group" style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border-light);">
<h4 style="margin: 0 0 10px 0; color: var(--text-main);">🏖️ <?= tr('fc_leave_catalog_title') ?></h4>
<p style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 15px;">
<?= tr('fc_leave_catalog_desc') ?>
</p>
<div id="leaveTypesContainer" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px;"></div>
<div style="background: var(--bg-subtle); padding: 12px; border-radius: 8px; border: 1px solid var(--border-light);">
<h5 id="leaveTypeFormTitle" style="margin: 0 0 10px 0;">+ <?= tr('fc_add_leave_type') ?></h5>
<form id="formLeaveType" style="display: flex; flex-direction: column; gap: 10px;">
<input type="hidden" id="lt-mode" value="add">
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<div style="flex: 0 0 80px;">
<label class="pf-label"><?= tr('fc_col_code') ?> *</label>
<input type="text" id="lt-code" class="pf-input" maxlength="4" style="text-transform: uppercase;" required>
<small class="pf-muted-note" id="lt-code-note"><?= tr('fc_code_irreversible') ?></small>
</div>
<div style="flex: 1; min-width: 150px;">
<label class="pf-label"><?= tr('fc_col_label') ?> *</label>
<input type="text" id="lt-label" class="pf-input" placeholder="<?= tr('fc_leave_label_ph') ?>" required>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 5px;">
<button type="button" class="pf-btn btn-secondary" onclick="resetLeaveTypeForm()"><?= tr('btn_cancel') ?></button>
<button type="button" class="pf-btn pf-btn-primary" onclick="saveLeaveType()"><?= tr('btn_save') ?></button>
</div>
</form>
</div>
</div>
</div>
<div id="cal-pane-membres" class="cal-settings-pane" style="display: none;">
<div style="margin-bottom: 1.2rem;">
<label class="pf-label">Sélectionner un membre de la famille :</label>
<select id="selectCalMember" class="pf-input" style="width: 100%; font-weight: 600;" onchange="loadMemberConfigView()"></select>
<label class="pf-label"><?= tr('fc_select_member') ?></label>
<select id="selectCalMember" class="pf-input" style="width: 100%; font-weight: 600;" onchange="loadMemberConfigView()">
</select>
</div>
<div id="memberConfigZone" style="background: var(--bg-page); padding: 14px; border-radius: 8px; border: 1px solid var(--border-light); min-height: 100px;">
<div id="memberConfigZone" style="background: var(--bg-page); padding: 14px; border-radius: 8px; border: 1px solid var(--border-light); min-height: 150px;">
</div>
</div>
</div>
</div>
</div>
+415 -143
View File
@@ -15,27 +15,24 @@ $activePage = "gift-list";
$mainClass = "pf-gift-list";
$pageCss = "/modules/gift-list/gift-list.css";
// --- 2. RÉCUPÉRATION DYNAMIQUE DES DONNÉES (Multi-tenant) ---
$stmt = $pdo->query("SELECT name FROM pf_people WHERE role = 'enfant' AND is_active = 1 ORDER BY name ASC");
// --- 2. RÉCUPÉRATION DYNAMIQUE DES DONNÉES ---
// On inclut les enfants du foyer ET les proches enfants
$stmt = $pdo->query("SELECT name FROM pf_people WHERE role IN ('enfant', 'proche_enfant') ORDER BY name ASC");
$children = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: ['Aucun enfant configuré'];
$stmt = $pdo->query("SELECT name FROM pf_people WHERE role NOT IN ('enfant', 'nounou') AND is_active = 1 ORDER BY name ASC");
// On inclut les adultes du foyer ET les proches adultes (On exclut nounou et les enfants)
$stmt = $pdo->query("SELECT name FROM pf_people WHERE role NOT IN ('enfant', 'proche_enfant', 'nounou') ORDER BY name ASC");
$allAdultsList = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: ['Aucun adulte configuré'];
$stmt = $pdo->query("SELECT code, name, month_date FROM pf_gift_occasions WHERE is_active = 1 ORDER BY month_date ASC, id ASC");
// On récupère toutes les occasions (avec l'icône)
$stmt = $pdo->query("SELECT code, name, icon, month_date FROM pf_gift_occasions WHERE is_active = 1 ORDER BY sort_order ASC, month_date ASC, id ASC");
$activeOccasions = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($activeOccasions)) {
$activeOccasions = [['code' => 'DEMO', 'name' => 'Fête par défaut', 'month_date' => null]];
$activeOccasions = [['code' => 'DEMO', 'name' => 'Fête par défaut', 'icon' => '🎁', 'month_date' => null]];
}
$allowedOccasionCodes = array_column($activeOccasions, 'code');
$occasionIcons = [
'TIO' => '/modules/gift-list/assets/img/tio.png', 'NOEL' => '/modules/gift-list/assets/img/santa.png',
'ROIS' => '/modules/gift-list/assets/img/reis.png', 'ANNIV' => '/modules/gift-list/assets/img/corona.png',
'SANT' => '/modules/gift-list/assets/img/sant.png'
];
// --- 3. CHARGEMENT DES CADEAUX ---
$inMarks = implode(',', array_fill(0, count($allowedOccasionCodes), '?'));
$sql = "SELECT * FROM pf_gifts WHERE year = ? AND occasion IN ($inMarks) ORDER BY occasion ASC, child_name ASC, adult_name ASC";
@@ -89,8 +86,12 @@ require __DIR__ . '/header.php';
<div class="pf-container cl-view-dynamic">
<div class="cl-titlebar">
<h1>🎁 <?= sprintf(tr('gift_main_title'), $year) ?></h1>
<button class="btn btn-ghost btn-icon" id="btn-open-gift-settings" title="<?= tr('settings') ?>">⚙️</button>
<div style="display: flex; align-items: center; gap: 12px;">
<h1 style="margin: 0;">🎁 <?= sprintf(tr('gift_main_title'), $year) ?></h1>
<button id="btn-open-gift-settings" class="btn-settings-gear" title="<?= tr('gift_settings') ?>" style="font-size: 1.4rem; padding: 4px;">
⚙️
</button>
</div>
</div>
<div class="pf-filter-bar">
@@ -130,15 +131,13 @@ require __DIR__ . '/header.php';
<section class="pf-section">
<?php foreach ($activeOccasions as $occ):
$occCode = $occ['code'];
// Affichage Brut de la BDD pour le nom de l'occasion
$occName = $occ['name'];
$occIcon = $occ['icon'] ?: '🎁';
?>
<div class="js-occ-section">
<h2 class="cl-occasion-title">
<?php if(!empty($occasionIcons[$occCode])): ?>
<img src="<?= $occasionIcons[$occCode] ?>" class="cl-occasion-icon" alt="">
<?php else: ?>
<span>🎀</span>
<?php endif; ?>
<span class="cl-occasion-icon"><?= htmlspecialchars($occIcon) ?></span>
<?= htmlspecialchars($occName) ?>
</h2>
@@ -197,7 +196,7 @@ require __DIR__ . '/header.php';
</section>
<section class="pf-section pf-section--panel gift-tricount-section">
<h2 class="gift-tricount-title">⚖️ <?= tr('gift_liquidations') ?? 'Bilan & Remboursements' ?></h2>
<h2 class="gift-tricount-title">⚖️ <?= tr('gift_liquidations') ?></h2>
<?php if (empty($settlements)): ?>
<p class="gift-tricount-success">✅ <?= tr('gift_no_debt') ?></p>
@@ -205,7 +204,7 @@ require __DIR__ . '/header.php';
<ul class="pf-tricount-list">
<?php foreach ($settlements as $s): ?>
<li class="pf-tricount-item">
<span><strong><?= htmlspecialchars($s['from']) ?></strong> <?= tr('gift_owes') ?> à <?= htmlspecialchars($s['to']) ?></span>
<span><strong><?= htmlspecialchars($s['from']) ?></strong> <?= tr('gift_owes') ?> <?= tr('gift_to') ?> <?= htmlspecialchars($s['to']) ?></span>
<strong class="gift-tricount-debt-amount"><?= number_format($s['amount'], 2, ',', ' ') ?> €</strong>
</li>
<?php endforeach; ?>
@@ -242,6 +241,7 @@ require __DIR__ . '/header.php';
</section>
</div>
<!-- 1. Modale CRUD Cadeau -->
<div id="pf-gift-modal" class="pf-modal">
<div class="pf-modal-content gift-modal-custom-content">
<div class="gift-modal-custom-header">
@@ -270,33 +270,86 @@ require __DIR__ . '/header.php';
</div>
</div>
<!-- 2. Modale Paramètres Fêtes -->
<div class="gift-settings-backdrop" id="modal-gift-settings">
<div class="gift-settings-modal">
<div class="gift-settings-header">
<h3>⚙️ <?= tr('gift_settings_title') ?? 'Configuration' ?></h3>
<button class="gift-settings-close" id="btn-close-gift-settings">×</button>
<!-- 2. Modale Paramètres (Occasions & Proches) -->
<div id="modal-gift-settings" class="pf-modal">
<div class="pf-modal-content" style="max-width: 600px; width: 90%;">
<div class="pf-modal-header" style="display: flex; justify-content: space-between; align-items: center;">
<h3 class="pf-modal-title" style="margin: 0;">⚙️ <?= tr('gift_settings') ?></h3>
<button type="button" class="pf-modal-close" style="background:none; border:none; font-size:1.5rem; cursor:pointer;" onclick="closeGiftSettings()">&times;</button>
</div>
<div class="gift-settings-body">
<h4 class="gift-add-subtitle"><?= tr('gift_settings_add_title') ?? '+ Ajouter' ?></h4>
<form id="form-add-occasion" class="gift-add-form">
<input type="text" class="pf-input" name="name" placeholder="<?= tr('gift_settings_name_placeholder') ?? 'Nom' ?>" required style="flex: 1; min-width: 150px; padding: 0.4rem 0.75rem;">
<input type="text" class="pf-input" name="month_date" placeholder="<?= tr('gift_settings_date_placeholder') ?? 'MM-JJ' ?>" style="width: 120px; padding: 0.4rem 0.75rem;">
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?? 'Ajouter' ?></button>
</form>
<small class="gift-help-text"><?= tr('gift_settings_date_help') ?? 'Optionnel' ?></small>
<div style="display: flex; gap: 4px; background: var(--bg-page); padding: 4px; border-radius: 8px; margin: 1rem 0;">
<button type="button" id="tab-btn-occasions" class="bs-tab-btn active" style="flex: 1; padding: 8px 12px; font-weight: 600; border-radius: 6px; border: none; cursor: pointer; transition: 0.2s;" onclick="switchGLTab('occasions')"><?= tr('gift_tab_occasions') ?></button>
<button type="button" id="tab-btn-proches" class="bs-tab-btn" style="flex: 1; padding: 8px 12px; font-weight: 600; border-radius: 6px; border: none; cursor: pointer; transition: 0.2s; background: transparent;" onclick="switchGLTab('proches')"><?= tr('gift_tab_proches') ?></button>
</div>
<hr style="border:0; border-bottom:1px solid var(--border-light); margin: 1rem 0;">
<div class="pf-modal-body" style="min-height: 450px; max-height: 60vh; overflow-y: auto; padding: 0 1rem 1rem 1rem; display: flex; flex-direction: column;">
<form id="form-save-toggles">
<div id="occasions-list-container" class="gift-occasions-list"></div>
<div id="gl-pane-occasions" class="gl-pane active" style="display: block; animation: fadeIn 0.3s ease;">
<div id="occasions-list" style="margin-bottom: 20px;">⏳ <?= tr('loading') ?></div>
<div class="modal-footer gift-modal-custom-footer" style="padding-top: 1rem; border-top: 1px solid var(--border-light);">
<button type="button" class="btn btn-secondary" id="btn-cancel-settings"><?= tr('btn_cancel') ?? 'Annuler' ?></button>
<button type="submit" class="btn btn-primary"><?= tr('btn_save') ?? 'Enregistrer' ?></button>
<hr style="border:0; border-top: 1px solid var(--border-light); margin: 20px 0;">
<h5 style="margin: 0 0 15px 0; font-size: 1rem; color: var(--text-main);"><?= tr('gift_settings_add_occ') ?></h5>
<form id="form-add-occasion" style="display: flex; flex-direction: column; gap: 16px;">
<div>
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_settings_icon') ?></label>
<input type="hidden" name="icon" id="add-occ-icon" value="🎁">
<div class="emoji-picker" style="display: flex; gap: 8px; flex-wrap: wrap;">
<div class="emoji-option selected" onclick="selectEmoji(this, 'add-occ-icon', '🎁')">🎁</div>
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎄')">🎄</div>
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎂')">🎂</div>
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎉')">🎉</div>
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎓')">🎓</div>
</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<div style="flex: 1; min-width: 150px;">
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_modal_gift_name') ?> *</label>
<input type="text" name="name" id="add-occ-name" class="pf-input" style="width: 100%;" required>
</div>
<div style="width: 110px;">
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_settings_date') ?></label>
<input type="text" name="month_date" id="add-occ-date" class="pf-input" style="width: 100%;" placeholder="MM-DD">
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px;">
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('form-add-occasion').reset()"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn pf-btn-primary"><?= tr('btn_add') ?></button>
</div>
</form>
</div>
<div id="gl-pane-proches" class="gl-pane" style="display: none; animation: fadeIn 0.3s ease;">
<div id="proches-list" style="margin-bottom: 20px;">⏳ <?= tr('loading') ?></div>
<hr style="border:0; border-top: 1px solid var(--border-light); margin: auto 0 20px 0;">
<h5 style="margin: 0 0 15px 0; font-size: 1rem; color: var(--text-main);"><?= tr('gift_settings_add_proche') ?></h5>
<form id="form-add-proche" style="display: flex; flex-direction: column; gap: 16px;">
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<div style="flex: 1; min-width: 150px;">
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_ph_proche_name') ?> *</label>
<input type="text" name="name" id="add-proche-name" class="pf-input" style="width: 100%;" required>
</div>
<div style="flex: 1; min-width: 150px;">
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_role_label') ?> *</label>
<select name="role" id="add-proche-role" class="pf-input" style="width: 100%;" required>
<option value="proche_adulte"><?= tr('gift_role_proche_adulte') ?></option>
<option value="proche_enfant"><?= tr('gift_role_proche_enfant') ?></option>
</select>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px;">
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('form-add-proche').reset()"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn pf-btn-primary"><?= tr('btn_add') ?></button>
</div>
</form>
</div>
</div>
</div>
@@ -306,6 +359,7 @@ require __DIR__ . '/header.php';
// ==========================================
// 1. GESTION DU COMPOSANT MULTI-SELECT VANILLA
// ==========================================
function toggleMS(listId, triggerEl) {
document.querySelectorAll('.pf-ms-dropdown').forEach(el => { if (el.id !== listId) el.classList.remove('open'); });
document.querySelectorAll('.pf-ms-trigger').forEach(el => { if (el !== triggerEl) el.classList.remove('active'); });
@@ -546,157 +600,375 @@ if (giftForm) {
}
// ==========================================
// GESTION DES PARAMÈTRES (MODALE FÊTES ⚙️)
// 3. GESTION DES PARAMÈTRES (MODALE FÊTES & PROCHES)
// ==========================================
// 1. Fonctions d'ouverture / fermeture propres
function closeGiftSettings() {
document.getElementById('modal-gift-settings').classList.remove('show');
document.getElementById('modal-gift-settings').classList.remove('open');
document.body.classList.remove('no-scroll');
}
const btnOpenSettings = document.getElementById('btn-open-gift-settings');
if (btnOpenSettings) {
btnOpenSettings.addEventListener('click', async (e) => {
document.getElementById('btn-open-gift-settings')?.addEventListener('click', async (e) => {
e.preventDefault();
const modalSettings = document.getElementById('modal-gift-settings');
if (modalSettings) {
modalSettings.classList.add('show');
document.getElementById('modal-gift-settings').classList.add('open');
document.body.classList.add('no-scroll');
await loadGiftOccasions();
}
await loadSettingsData();
});
document.querySelectorAll('.btn-close-settings').forEach(btn => btn.addEventListener('click', closeGiftSettings));
function switchGLTab(tabId) {
// Gestion du contenu (Afficher/Cacher)
document.getElementById('gl-pane-occasions').style.display = (tabId === 'occasions') ? 'block' : 'none';
document.getElementById('gl-pane-proches').style.display = (tabId === 'proches') ? 'block' : 'none';
// Gestion du style des boutons d'onglet
const btnOcc = document.getElementById('tab-btn-occasions');
const btnProche = document.getElementById('tab-btn-proches');
if (tabId === 'occasions') {
btnOcc.style.background = 'var(--bg-panel)';
btnOcc.style.color = 'var(--primary)';
btnOcc.style.boxShadow = 'var(--shadow-sm)';
btnProche.style.background = 'transparent';
btnProche.style.color = 'var(--text-muted)';
btnProche.style.boxShadow = 'none';
} else {
btnProche.style.background = 'var(--bg-panel)';
btnProche.style.color = 'var(--primary)';
btnProche.style.boxShadow = 'var(--shadow-sm)';
btnOcc.style.background = 'transparent';
btnOcc.style.color = 'var(--text-muted)';
btnOcc.style.boxShadow = 'none';
}
}
document.getElementById('btn-close-gift-settings')?.addEventListener('click', closeGiftSettings);
document.getElementById('btn-cancel-settings')?.addEventListener('click', closeGiftSettings);
// Fermeture au clic sur le fond (backdrop)
const modalGiftSettings = document.getElementById('modal-gift-settings');
if (modalGiftSettings) {
modalGiftSettings.addEventListener('click', (e) => {
if (e.target === modalGiftSettings) {
closeGiftSettings();
}
});
}
// 2. Chargement dynamique de la liste
async function loadGiftOccasions() {
const container = document.getElementById('occasions-list-container');
if (!container) return;
const txtLoading = window.I18N && window.I18N['loading'] ? window.I18N['loading'] : '⏳';
const txtEmpty = window.I18N && window.I18N['gift_settings_empty'] ? window.I18N['gift_settings_empty'] : 'Vide';
const txtActive = window.I18N && window.I18N['gift_settings_active'] ? window.I18N['gift_settings_active'] : 'Actif';
container.innerHTML = `<div class="gift-loader">${txtLoading}</div>`;
let hasSettingsChanged = false;
async function loadSettingsData() {
try {
const res = await fetch('/modules/gift-list/api-settings.php?action=get_occasions');
const text = await res.text();
const data = JSON.parse(text);
const res = await pachaFetch('/modules/gift-list/api-settings.php?action=get_all');
if (!res.success) throw new Error(res.error);
if (!data.ok) throw new Error(data.error);
if (data.data.length === 0) {
container.innerHTML = `<em>${txtEmpty}</em>`;
return;
renderOccasions(res.data.occasions);
renderProches(res.data.proches);
} catch (e) {
document.getElementById('occasions-list').innerHTML = `<div class="pf-alert pf-alert--error">Erreur : ${e.message}</div>`;
document.getElementById('proches-list').innerHTML = `<div class="pf-alert pf-alert--error">Erreur : ${e.message}</div>`;
}
}
let html = '<div class="gift-occasions-grid">';
data.data.forEach(occ => {
const isChecked = occ.is_active == 1 ? 'checked' : '';
const dateBadge = occ.month_date ? `<span class="gift-date-badge">${occ.month_date}</span>` : '';
function renderOccasions(occasions) {
const container = document.getElementById('occasions-list');
if (!occasions.length) { container.innerHTML = `<em class="gift-empty-state">Vide</em>`; return; }
let html = '';
occasions.forEach(occ => {
const isChecked = occ.is_active == 1 ? 'checked' : '';
// ⚠️ Remarque l'ajout de id="row-occ-${occ.id}" ici :
html += `
<div class="pf-card gift-occasion-card">
<div class="gl-list-item" id="row-occ-${occ.id}">
<div>
<strong>${occ.name}</strong> ${dateBadge}
<span style="font-size:1.2rem; margin-right:8px;">${occ.icon || '🎁'}</span>
<strong>${occ.name}</strong>
<small style="color:var(--text-muted); margin-left:8px;">${occ.month_date || ''}</small>
</div>
<label class="gift-occasion-toggle">
<input type="checkbox" class="occ-toggle-cb" data-id="${occ.id}" ${isChecked}>
<small>${txtActive}</small>
<div class="pf-flex-gap-8 align-center">
<label style="display:flex; align-items:center; cursor:pointer;">
<input type="checkbox" class="pf-checkbox-lg" ${isChecked} onchange="toggleOccasion(${occ.id}, this.checked)">
</label>
<button type="button" class="btn-icon-action edit" onclick="transformRowToEditOccasion('row-occ-${occ.id}', ${occ.id}, '${occ.name.replace(/'/g, "\\'")}', '${occ.icon}', '${occ.month_date || ''}')" title="${tr('btn_edit')}">✏️</button>
<button type="button" class="btn-icon-action delete" onclick="deleteOccasion(${occ.id})" title="${tr('btn_delete')}">🗑️</button>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
} catch (e) {
container.innerHTML = `<div class="gift-error-msg">Erreur : ${e.message}</div>`;
}
}
// 3. Soumission du formulaire d'AJOUT
const formAddOccasion = document.getElementById('form-add-occasion');
if (formAddOccasion) {
formAddOccasion.addEventListener('submit', async (e) => {
function renderProches(proches) {
const container = document.getElementById('proches-list');
if (!proches.length) { container.innerHTML = `<em class="gift-empty-state">Aucun proche configuré.</em>`; return; }
let html = '';
proches.forEach(p => {
const roleLabel = p.role === 'proche_adulte' ? (tr('gift_role_proche_adulte') || 'Adulte') : (tr('gift_role_proche_enfant') || 'Enfant');
html += `
<div class="gl-list-item" id="row-proche-${p.id}">
<div><strong>${p.name}</strong> <small style="color:var(--text-muted);">— ${roleLabel}</small></div>
<div class="pf-flex-gap-8 align-center">
<button type="button" class="btn-icon-action edit" onclick="transformRowToEditProche('row-proche-${p.id}', ${p.id}, '${p.name.replace(/'/g, "\\'")}', '${p.role}')" title="${tr('btn_edit')}">✏️</button>
<button type="button" class="btn-icon-action delete" onclick="deleteProche(${p.id})" title="${tr('btn_delete')}">🗑️</button>
</div>
</div>
`;
});
container.innerHTML = html;
}
// ==========================================
// CRUD : OCCASIONS
// ==========================================
// 1. Ajouter une occasion
document.getElementById('form-add-occasion')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = formAddOccasion.querySelector('button[type="submit"]');
const oldText = btn.innerText;
btn.innerText = '⏳';
const btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
hasSettingsChanged = true;
const formData = new FormData(formAddOccasion);
formData.append('action', 'add_occasion');
const fd = new FormData(e.target);
fd.append('action', 'save_occasion');
try {
const res = await fetch('/modules/gift-list/api-settings.php', { method: 'POST', body: formData });
const text = await res.text();
const data = JSON.parse(text);
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
if (!data.ok) throw new Error(data.error || 'Erreur');
// On vide le formulaire
e.target.reset();
formAddOccasion.reset();
await loadGiftOccasions(); // Rafraîchit juste la liste dans la modale !
// On remet l'émoji par défaut visuellement
document.querySelectorAll('#form-add-occasion .emoji-option').forEach(el => el.classList.remove('selected'));
document.querySelector('#form-add-occasion .emoji-option').classList.add('selected');
document.getElementById('add-occ-icon').value = '🎁';
showToast("Fête ajoutée avec succès !", "success");
loadSettingsData(); // Recharge la liste
} catch (err) {
alert("Erreur: " + err.message);
showToast("Erreur lors de l'ajout", "error");
console.error(err);
} finally {
btn.innerText = oldText;
btn.disabled = false;
}
});
}
// 4. Soumission GLOBALE (Bouton Enregistrer) pour les Checkboxes
const formSaveToggles = document.getElementById('form-save-toggles');
if (formSaveToggles) {
formSaveToggles.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = formSaveToggles.querySelector('button[type="submit"]');
const oldText = btn.innerText;
btn.innerText = '⏳';
btn.disabled = true;
// On récolte l'état de toutes les checkboxes
const states = [];
formSaveToggles.querySelectorAll('.occ-toggle-cb').forEach(cb => {
states.push({ id: cb.dataset.id, state: cb.checked ? 1 : 0 });
});
const formData = new FormData();
formData.append('action', 'save_toggles');
formData.append('states', JSON.stringify(states));
// 2. Sauvegarder la modification (In-place)
async function saveInlineOccasion(id) {
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'save_occasion');
fd.append('id', id);
fd.append('icon', document.getElementById(`inline-occ-icon-${id}`).value);
fd.append('name', document.getElementById(`inline-occ-name-${id}`).value);
fd.append('month_date', document.getElementById(`inline-occ-date-${id}`).value);
try {
const res = await fetch('/modules/gift-list/api-settings.php', { method: 'POST', body: formData });
const text = await res.text();
const data = JSON.parse(text);
if (!data.ok) throw new Error(data.error);
// Succès : on rafraîchit la page pour afficher/masquer les onglets
window.location.reload();
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
showToast("Fête modifiée avec succès !", "success");
loadSettingsData();
} catch (err) {
alert("Erreur: " + err.message);
btn.innerText = oldText;
showToast("Erreur lors de la modification", "error");
console.error(err);
}
}
// 3. Supprimer une occasion
async function deleteOccasion(id) {
if (!confirm(tr('confirm_delete') || 'Supprimer définitivement ?')) return;
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'delete_occasion');
fd.append('id', id);
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
showToast("Fête supprimée.", "success");
loadSettingsData();
} catch (err) {
showToast("Erreur lors de la suppression", "error");
}
}
// 4. Activer / Désactiver (Checkbox)
async function toggleOccasion(id, state) {
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'toggle_occasion');
fd.append('id', id);
fd.append('state', state ? 1 : 0);
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
showToast(state ? "Fête activée" : "Fête masquée", "success");
} catch (err) {
showToast("Erreur de mise à jour", "error");
}
}
// ==========================================
// CRUD : PROCHES
// ==========================================
// 1. Ajouter un proche
document.getElementById('form-add-proche')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
hasSettingsChanged = true;
const fd = new FormData(e.target);
fd.append('action', 'save_proche');
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
e.target.reset();
showToast("Proche ajouté avec succès !", "success");
loadSettingsData();
} catch (err) {
showToast("Erreur lors de l'ajout", "error");
} finally {
btn.disabled = false;
}
});
// 2. Sauvegarder la modification (In-place)
async function saveInlineProche(id) {
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'save_proche');
fd.append('id', id);
fd.append('name', document.getElementById(`inline-proche-name-${id}`).value);
fd.append('role', document.getElementById(`inline-proche-role-${id}`).value);
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
showToast("Proche mis à jour !", "success");
loadSettingsData();
} catch (err) {
showToast("Erreur lors de la mise à jour", "error");
}
}
// 3. Supprimer un proche
async function deleteProche(id) {
if (!confirm(tr('confirm_delete') || 'Supprimer ce proche ?')) return;
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'delete_proche');
fd.append('id', id);
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
showToast("Proche retiré de la liste.", "success");
loadSettingsData();
} catch (err) {
showToast("Erreur lors de la suppression", "error");
}
}
// Rechargement intelligent lors de la fermeture si modifications
document.getElementById('modal-gift-settings').addEventListener('click', (e) => {
if (e.target.id === 'modal-gift-settings' || e.target.classList.contains('btn-close-settings')) {
if (hasSettingsChanged) {
showToast(tr('gift_settings_saved') || 'Paramètres mis à jour !', 'success');
setTimeout(() => window.location.reload(), 1000);
} else {
closeGiftSettings();
}
}
});
// Fonction pour gérer la sélection dans la grille d'émojis (formulaire d'ajout)
function selectEmoji(element, targetInputId, emoji) {
document.getElementById(targetInputId).value = emoji;
// Mise à jour visuelle de la sélection
const siblings = element.parentNode.querySelectorAll('.emoji-option');
siblings.forEach(el => el.classList.remove('selected'));
element.classList.add('selected');
}
// ==========================================
// LOGIQUE D'ÉDITION IN-PLACE (OCCASIONS)
// ==========================================
// On crée une mémoire pour stocker le HTML d'origine de chaque ligne
const rowStates = {};
function transformRowToEditOccasion(rowId, id, name, icon, date) {
const row = document.getElementById(rowId);
// On sauvegarde l'état proprement en JS (fini les bugs d'affichage !)
rowStates[rowId] = row.innerHTML;
row.innerHTML = `
<div style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; background: var(--bg-subtle); padding: 8px; border-radius: 8px; border: 1px solid var(--border-light);">
<select id="inline-occ-icon-${id}" class="pf-input" style="width: 60px; padding: 6px; margin: 0;">
<option value="🎁" ${icon==='🎁'?'selected':''}>🎁</option>
<option value="🎄" ${icon==='🎄'?'selected':''}>🎄</option>
<option value="🎂" ${icon==='🎂'?'selected':''}>🎂</option>
<option value="🎉" ${icon==='🎉'?'selected':''}>🎉</option>
<option value="🎓" ${icon==='🎓'?'selected':''}>🎓</option>
</select>
<input type="text" id="inline-occ-name-${id}" value="${name}" class="pf-input" style="flex: 1; min-width: 100px; padding: 6px; margin: 0;">
<input type="text" id="inline-occ-date-${id}" value="${date}" class="pf-input" style="width: 70px; padding: 6px; margin: 0;" placeholder="MM-JJ">
<div style="display: flex; gap: 4px; margin-left: auto;">
<button type="button" onclick="saveInlineOccasion(${id})" class="pf-btn pf-btn-primary" style="padding: 6px 10px;">💾</button>
<button type="button" onclick="cancelInlineEdit('${rowId}')" class="pf-btn btn-secondary" style="padding: 6px 10px;">❌</button>
</div>
</div>
`;
}
async function saveInlineOccasion(id) {
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'save_occasion');
fd.append('id', id);
fd.append('icon', document.getElementById(`inline-occ-icon-${id}`).value);
fd.append('name', document.getElementById(`inline-occ-name-${id}`).value);
fd.append('month_date', document.getElementById(`inline-occ-date-${id}`).value);
// On met à jour en BDD puis on recharge la liste !
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
loadSettingsData();
}
// ==========================================
// LOGIQUE D'ÉDITION IN-PLACE (PROCHES)
// ==========================================
function transformRowToEditProche(rowId, id, name, role) {
const row = document.getElementById(rowId);
rowStates[rowId] = row.innerHTML;
const roleAdulte = window.I18N ? window.I18N['gift_role_proche_adulte'] : 'Adulte';
const roleEnfant = window.I18N ? window.I18N['gift_role_proche_enfant'] : 'Enfant';
row.innerHTML = `
<div style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; background: var(--bg-subtle); padding: 8px; border-radius: 8px; border: 1px solid var(--border-light);">
<input type="text" id="inline-proche-name-${id}" value="${name}" class="pf-input" style="flex: 1; min-width: 100px; padding: 6px; margin: 0;">
<select id="inline-proche-role-${id}" class="pf-input" style="width: 130px; padding: 6px; margin: 0;">
<option value="proche_adulte" ${role==='proche_adulte'?'selected':''}>${roleAdulte}</option>
<option value="proche_enfant" ${role==='proche_enfant'?'selected':''}>${roleEnfant}</option>
</select>
<div style="display: flex; gap: 4px; margin-left: auto;">
<button type="button" onclick="saveInlineProche(${id})" class="pf-btn pf-btn-primary" style="padding: 6px 10px;">💾</button>
<button type="button" onclick="cancelInlineEdit('${rowId}')" class="pf-btn btn-secondary" style="padding: 6px 10px;">❌</button>
</div>
</div>
`;
}
async function saveInlineProche(id) {
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'save_proche');
fd.append('id', id);
fd.append('name', document.getElementById(`inline-proche-name-${id}`).value);
fd.append('role', document.getElementById(`inline-proche-role-${id}`).value);
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
loadSettingsData();
}
// Fonction pour restaurer la ligne (sans casser le HTML)
function cancelInlineEdit(rowId) {
document.getElementById(rowId).innerHTML = rowStates[rowId];
}
</script>
<?php require __DIR__ . '/footer.php'; ?>
+87 -1
View File
@@ -280,6 +280,56 @@ return [
'btn_save_rights' => 'Guardar',
// ==========================================
// MODAL DE CONFIGURACIÓ DEL CALENDARI
// ==========================================
'fc_modal_settings_title' => 'Configuració del calendari',
'fc_tab_foyer' => 'Llar',
'fc_tab_members' => 'Membres i Drets',
// Llar: Zona escolar
'fc_zone_label' => 'Zona escolar',
'fc_zone_a' => 'Zona A',
'fc_zone_b' => 'Zona B',
'fc_zone_c' => 'Zona C',
'fc_zone_other' => 'Altra / Fora de França',
'fc_zone_desc' => 'Permet mostrar correctament les vacances escolars.',
// Llar: Opcions de cura (Modes de garde)
'fc_care_modes_label' => 'Modalitats de cura',
'fc_add_mode_placeholder' => 'Ex: Mainadera, Llar d\'infants...',
'fc_care_modes_desc' => 'Afegeix les diferents modalitats de cura per als teus fills.',
'fc_empty_kids_msg' => 'Actualment no hi ha cap infant configurat a la teva família.',
'fc_link_settings' => 'Anar a la configuració general',
// Llar: Catàleg de permisos (Congés)
'fc_leave_catalog_title' => 'Catàleg de permisos',
'fc_leave_catalog_desc' => 'Defineix els tipus de permisos possibles per a tota la família.',
'fc_add_leave_type' => 'Afegir un tipus de permís',
'fc_col_code' => 'Codi',
'fc_code_irreversible' => 'Irreversible',
'fc_col_label' => 'Etiqueta',
'fc_leave_label_ph' => 'Ex: Vacances, Assumptes propis...',
'fc_quota_default' => 'Quota per defecte',
'fc_reset_month' => 'Mes de renovació',
// Membres
'fc_select_member' => 'Selecciona un membre:',
// Mesos
'month_jan' => 'Gener',
'month_feb' => 'Febrer',
'month_mar' => 'Març',
'month_apr' => 'Abril',
'month_may' => 'Maig',
'month_jun' => 'Juny',
'month_jul' => 'Juliol',
'month_aug' => 'Agost',
'month_sep' => 'Setembre',
'month_oct' => 'Octubre',
'month_nov' => 'Novembre',
'month_dec' => 'Desembre',
'leg_presence' => 'Presència Pep',
'leg_school_holidays' => 'Vacances Escolars',
'leg_public_holiday' => 'Festiu',
@@ -592,9 +642,24 @@ return [
'bud_bar_max_cap' => "Capacitat Màx.",
'bud_edit_title' => 'Modificar la transacció',
'bud_to_define_js' => 'per definir',
'bud_select_beneficiary' => 'Triar un ingrés esperat', // 👈 À ajouter ici
'bud_select_beneficiary' => 'Triar un ingrés esperat', /
'bud_bar_actual' => 'Actual',
// ONBOARDING BUDGET
'bs_onb_title' => 'Benvinguts! Configurem el teu pressupost:',
'bs_onb_desc' => 'Completa aquests passos perquè l\'aplicació pugui calcular el teu pressupost disponible:',
'bs_onb_cat_link' => 'Categories',
'bs_onb_cat_desc' => 'Crea almenys una categoria de despeses.',
'bs_onb_acc_link' => 'Comptes Bancaris',
'bs_onb_acc_desc' => 'Afegeix almenys un compte corrent o d\'estalvi.',
'bs_onb_sal_link' => 'Sous',
'bs_onb_sal_desc' => 'Introdueix els ingressos de l\'any en curs.',
'bs_onb_items_link' => 'Línies de Pressupost',
'bs_onb_items_desc' => 'Ves a la pestanya Resum per llistar les teves despeses fixes (Lloguer, Assegurances...).',
'bs_rule_search_ph' => '🔍 Cercar una paraula clau (ex: AUCHAN)...',
'error_loading_settings' => 'Error en carregar la configuració',
'settings' => 'Configuració',
// --- MODULE BUDGET : IMPORT CSV ---
'bud_import_csv' => "Importar CSV",
'bud_csv_file' => "Seleccioneu el fitxer del vostre banc (.csv)",
@@ -652,6 +717,9 @@ return [
'bud_sav_modal_title_add' => 'Introduir un mes',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_prev_label_name' => 'Nom',
'bud_prev_monthly_target' => 'Import objectiu / Mensualitat (€)',
'bud_prev_transfer_dest' => 'Destinació de la transferència',
'bud_prev_none' => 'Cap destinació (Despesa estàndard)',
// --- BUDGET : EPARGNE ---
'bud_sav_add_month' => 'Introduir un mes',
@@ -750,6 +818,8 @@ return [
'bud_prev_err_no_history' => 'Impossible: no hi ha historial disponible per copiar.',
'bud_prev_confirm_copy' => "Vols copiar les dades de %s a %t?\n\n⚠️ Això sobreescriurà tots els valors ja presents per a %t.",
'bud_prev_confirm_transfers' => "Confirmes que %p ha fet totes les transferències per a %m?\n\nAixò actualitzarà l'Estalvi automàticament.",
'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_transfer_to' => 'Cap a',
// --- MODULE BUDGET : AVANCES & TRICOUNT ---
'bud_adv_title' => 'Avançaments i Tricount Familiar',
@@ -821,6 +891,22 @@ return [
'btn_add' => 'Afegir',
'loading' => 'Carregant...', //
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings' => 'Configuració',
'gift_tab_occasions' => 'Festes i Ocasions',
'gift_tab_proches' => 'Familiars (Fora de la llar)',
'gift_settings_icon' => 'Icona',
'gift_settings_date' => 'Data (MM-DD)',
'gift_settings_add_occ' => 'Afegeix una festa',
'gift_settings_add_proche'=> 'Afegeix un familiar',
'gift_role_proche_adulte'=> 'Adult (Oncle, amic...)',
'gift_role_proche_enfant'=> 'Nen (Nebot, neboda...)',
'gift_ph_proche_name' => 'Nom',
'gift_role_label' => 'Rol',
'gift_settings_saved' => 'Configuració desada!',
'gift_settings_edit_occ' => 'Modificar la festa',
'gift_settings_edit_proche' => 'Modificar el familiar',
// ==========================================
// GARAGE MANAGER
// ==========================================
+86
View File
@@ -281,6 +281,56 @@ return [
'btn_save_rights' => 'Save',
// ==========================================
// CALENDAR SETTINGS MODAL
// ==========================================
'fc_modal_settings_title' => 'Calendar Settings',
'fc_tab_foyer' => 'Household',
'fc_tab_members' => 'Members & Rights',
// Household: School Zone
'fc_zone_label' => 'School zone',
'fc_zone_a' => 'Zone A',
'fc_zone_b' => 'Zone B',
'fc_zone_c' => 'Zone C',
'fc_zone_other' => 'Other / Outside France',
'fc_zone_desc' => 'Used to accurately display school holidays.',
// Household: Care Modes
'fc_care_modes_label' => 'Childcare options',
'fc_add_mode_placeholder' => 'Ex: Nanny, Daycare...',
'fc_care_modes_desc' => 'Add the different childcare options for your children.',
'fc_empty_kids_msg' => 'No children are configured in your household yet.',
'fc_link_settings' => 'Go to general settings',
// Household: Leave Catalog
'fc_leave_catalog_title' => 'Leave Catalog',
'fc_leave_catalog_desc' => 'Define the possible leave types for the whole family.',
'fc_add_leave_type' => 'Add a leave type',
'fc_col_code' => 'Code',
'fc_code_irreversible' => 'Irreversible',
'fc_col_label' => 'Label',
'fc_leave_label_ph' => 'Ex: PTO, Sick leave...',
'fc_quota_default' => 'Default quota',
'fc_reset_month' => 'Reset month',
// Members
'fc_select_member' => 'Select a member:',
// Months
'month_jan' => 'January',
'month_feb' => 'February',
'month_mar' => 'March',
'month_apr' => 'April',
'month_may' => 'May',
'month_jun' => 'June',
'month_jul' => 'July',
'month_aug' => 'August',
'month_sep' => 'September',
'month_oct' => 'October',
'month_nov' => 'November',
'month_dec' => 'December',
'leg_presence' => 'Pep presence',
'leg_school_holidays' => 'School holidays',
'leg_public_holiday' => 'Public holiday',
@@ -532,6 +582,21 @@ return [
'bs_csv_empty_col' => 'Empty',
'bs_csv_no_data' => 'No transaction row found in this file.',
// ONBOARDING BUDGET
'bs_onb_title' => 'Welcome! Let\'s set up your budget:',
'bs_onb_desc' => 'Complete these steps so the app can calculate your remaining budget:',
'bs_onb_cat_link' => 'Categories',
'bs_onb_cat_desc' => 'Create at least one expense category.',
'bs_onb_acc_link' => 'Bank Accounts',
'bs_onb_acc_desc' => 'Add at least one checking or savings account.',
'bs_onb_sal_link' => 'Salaries',
'bs_onb_sal_desc' => 'Enter your income for the current year.',
'bs_onb_items_link' => 'Budget Items',
'bs_onb_items_desc' => 'Go to the Summary tab to list your fixed expenses (Rent, Insurance...).',
'bs_rule_search_ph' => '🔍 Search for a keyword (e.g. WALMART)...',
'error_loading_settings' => 'Error loading settings',
'settings' => 'Settings',
// --- BUDGET: TRACKING ---
'bud_rem_school' => 'School (estimated remaining)',
'bud_rem_fmcg' => 'Groceries (estimated remaining)',
@@ -630,6 +695,9 @@ return [
'bud_reg_none' => 'None',
'bud_recap_confirm_delete' => 'Are you sure you want to delete this item?',
'bud_err_delete' => 'Error while deleting.',
'bud_prev_monthly_target' => 'Target amount / Monthly payment (€)',
'bud_prev_transfer_dest' => 'Transfer destination',
'bud_prev_none' => 'No destination (Standard expense)',
// --- BUDGET: SAVINGS ---
'bud_sav_add_month' => 'Enter a month',
@@ -753,6 +821,8 @@ return [
'delete' => 'Delete',
'btn_cancel' => 'Cancel',
'btn_save' => 'Save',
'bud_prev_target_ph' => 'E.g., 150',
'bud_prev_transfer_to' => 'To',
// ==========================================
// MODULE: GIFTS (gift-list)
@@ -806,6 +876,22 @@ return [
'btn_add' => 'Add',
'loading' => 'Loading...',
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings' => 'Settings',
'gift_tab_occasions' => 'Occasions & Events',
'gift_tab_proches' => 'Relatives (Outside household)',
'gift_settings_icon' => 'Icon',
'gift_settings_date' => 'Date (MM-DD)',
'gift_settings_add_occ' => 'Add an occasion',
'gift_settings_add_proche'=> 'Add a relative',
'gift_role_proche_adulte'=> 'Adult (Uncle, friend...)',
'gift_role_proche_enfant'=> 'Child (Nephew, niece...)',
'gift_ph_proche_name' => 'First name',
'gift_role_label' => 'Role',
'gift_settings_saved' => 'Settings saved!',
'gift_settings_edit_occ' => 'Edit occasion',
'gift_settings_edit_proche' => 'Edit relative',
// ==========================================
// GARAGE MANAGER
// ==========================================
+87
View File
@@ -283,6 +283,55 @@ return [
'btn_save_rights' => 'Enregistrer',
// ==========================================
// MODALE PARAMÈTRES CALENDRIER
// ==========================================
'fc_modal_settings_title' => 'Paramètres du calendrier',
'fc_tab_foyer' => 'Foyer',
'fc_tab_members' => 'Membres & Droits',
// Foyer : Zone scolaire
'fc_zone_label' => 'Zone scolaire',
'fc_zone_a' => 'Zone A',
'fc_zone_b' => 'Zone B',
'fc_zone_c' => 'Zone C',
'fc_zone_other' => 'Autre / Hors France',
'fc_zone_desc' => 'Permet d\'afficher correctement les vacances scolaires.',
// Foyer : Modes de garde
'fc_care_modes_label' => 'Modes de garde',
'fc_add_mode_placeholder' => 'Ex: Nounou, Crèche...',
'fc_care_modes_desc' => 'Ajoutez les différents modes de garde pour vos enfants.',
'fc_empty_kids_msg' => 'Aucun enfant n\'est configuré dans votre famille pour le moment.',
'fc_link_settings' => 'Aller aux paramètres généraux',
// Foyer : Catalogue des congés
'fc_leave_catalog_title' => 'Catalogue des congés',
'fc_leave_catalog_desc' => 'Définissez les types de congés possibles pour toute la famille.',
'fc_add_leave_type' => 'Ajouter un type de congé',
'fc_col_code' => 'Code',
'fc_code_irreversible' => 'Irréversible',
'fc_col_label' => 'Label',
'fc_leave_label_ph' => 'Ex: RTT, Récup...',
'fc_quota_default' => 'Quota par défaut',
'fc_reset_month' => 'Mois de renouv.',
// Membres
'fc_select_member' => 'Sélectionner un membre :',
// Mois (S'ils n'existent pas déjà dans ton système)
'month_jan' => 'Janvier',
'month_feb' => 'Février',
'month_mar' => 'Mars',
'month_apr' => 'Avril',
'month_may' => 'Mai',
'month_jun' => 'Juin',
'month_jul' => 'Juillet',
'month_aug' => 'Août',
'month_sep' => 'Septembre',
'month_oct' => 'Octobre',
'month_nov' => 'Novembre',
'month_dec' => 'Décembre',
'leg_presence' => 'Présence Pep',
@@ -608,6 +657,21 @@ return [
'bud_select_beneficiary' => 'Choisir un revenu attendu',
'bud_bar_actual' => 'Actuel',
// ONBOARDING BUDGET
'bs_onb_title' => 'Bienvenue ! Configurons votre budget :',
'bs_onb_desc' => 'Complétez ces étapes pour que l\'application puisse calculer votre Reste à Vivre :',
'bs_onb_cat_link' => 'Catégories',
'bs_onb_cat_desc' => 'Créez au moins une catégorie de dépenses.',
'bs_onb_acc_link' => 'Comptes Bancaires',
'bs_onb_acc_desc' => 'Ajoutez au moins un compte courant ou épargne.',
'bs_onb_sal_link' => 'Salaires',
'bs_onb_sal_desc' => 'Renseignez les rentrées d\'argent de l\'année en cours.',
'bs_onb_items_link' => 'Lignes de Budget',
'bs_onb_items_desc' => 'Rendez-vous dans l\'onglet Récapitulatif pour lister vos charges fixes (Loyer, Assurances...).',
'bs_rule_search_ph' => '🔍 Rechercher un mot-clé (ex: AUCHAN)...',
'error_loading_settings' => 'Erreur lors du chargement des paramètres',
'settings' => 'Paramètres',
// --- BUDGET : RECAP ---
'bud_recap_monthly_title' => 'Récapitulatif Mensuel',
'bud_recap_btn_add' => 'Ajouter un frais / revenu',
@@ -649,6 +713,10 @@ return [
'bud_reg_none' => 'Aucune',
'bud_recap_confirm_delete' => 'Voulez-vous vraiment supprimer cet élément ?',
'bud_err_delete' => 'Erreur lors de la suppression.',
'bud_prev_monthly_target' => 'Montant cible / Mensualité (€)',
'bud_prev_transfer_dest' => 'Destination du virement',
'bud_prev_none' => 'Aucune destination (Charge standard)',
// --- BUDGET : EPARGNE ---
'bud_sav_add_month' => 'Saisir un mois',
@@ -704,6 +772,7 @@ return [
'bud_sav_selection' => 'Sélection :',
'bud_sav_ph_name' => 'Nom du compte',
// JS & Erreurs
'bud_sav_confirm_delete_month' => 'Voulez-vous vraiment supprimer toutes les données de %m pour %o ?',
'bud_sav_prompt_duplicate' => "Voulez-vous dupliquer les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
@@ -766,6 +835,8 @@ return [
'bud_adv_ph_desc' => 'Ex: Courses, Facture Électricité...',
'bud_adv_confirm_resolve' => 'Confirmer le remboursement complet de cette avance ?',
'bud_adv_confirm_delete' => 'Supprimer définitivement cette avance ?',
'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_transfer_to' => 'Vers',
// ==========================================
// MODULE : CADEAUX (gift-list)
@@ -819,6 +890,22 @@ return [
'btn_add' => 'Ajouter',
'loading' => 'Chargement...',
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings' => 'Configuration',
'gift_tab_occasions' => 'Fêtes & Occasions',
'gift_tab_proches' => 'Proches (Hors foyer)',
'gift_settings_icon' => 'Icône',
'gift_settings_date' => 'Date (MM-JJ)',
'gift_settings_add_occ' => 'Ajouter une fête',
'gift_settings_add_proche'=> 'Ajouter un proche',
'gift_role_proche_adulte'=> 'Adulte (Oncle, ami...)',
'gift_role_proche_enfant'=> 'Enfant (Neveu, nièce...)',
'gift_ph_proche_name' => 'Prénom',
'gift_role_label' => 'Rôle',
'gift_settings_saved' => 'Paramètres enregistrés !',
'gift_settings_edit_occ' => 'Modifier la fête',
'gift_settings_edit_proche' => 'Modifier le proche',
// ==========================================
// GARAGE MANAGER
// ==========================================
+91 -555
View File
@@ -1,644 +1,180 @@
<?php
/**
* Script de migration global HouseHub OS (Multi-tenant)
* Auto-Sync dynamique basé sur schema_family.sql + Migration des données
* À exécuter via le navigateur : http://localhost:8083/migrate.php
*/
require_once __DIR__ . '/includes/meta_db.php';
$db_host = getenv('DB_HOST') ?: '127.0.0.1';
$db_user = getenv('DB_USER') ?: 'househub';
$db_pass = getenv('DB_PASS') ?: 'househub_dev';
echo "<h1>🚀 Début de la migration Multi-Tenant (Auto-Sync)</h1>";
// ---------------------------------------------------------
// 1. LECTURE ET PARSING DU FICHIER SCHEMA_FAMILY.SQL
// ---------------------------------------------------------
$schemaPath = __DIR__ . '/schema_family.sql'; // Modifie si rangé dans /docker/
if (!file_exists($schemaPath)) {
$schemaPath = __DIR__ . '/docker/schema_family.sql';
}
if (!file_exists($schemaPath)) {
die("❌ Impossible de trouver le fichier schema_family.sql");
}
echo "<h1>🚀 Début de la migration Multi-Tenant</h1>";
$sqlContent = file_get_contents($schemaPath);
/**
* Analyse le code SQL pour en extraire la structure [Table => [Colonnes]]
*/
function parseExpectedSchema($sql) {
$schema = [];
// Récupère tout ce qui se trouve entre CREATE TABLE (...) ENGINE
preg_match_all('/CREATE TABLE (?:IF NOT EXISTS )?`?([a-zA-Z0-9_]+)`?\s*\((.*?)\)\s*ENGINE/si', $sql, $tableMatches, PREG_SET_ORDER);
foreach($tableMatches as $match) {
$tableName = $match[1];
$body = $match[2];
// Sépare les lignes par virgule, en ignorant les virgules entre parenthèses (ex: DECIMAL(10,2) ou ENUM('a','b'))
$lines = preg_split('/,(?![^\(]*\))/', $body);
$columns = [];
foreach($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
// On ignore la déclaration des clés, contraintes et index
if (preg_match('/^(PRIMARY KEY|UNIQUE KEY|FOREIGN KEY|KEY|INDEX|FULLTEXT KEY|CONSTRAINT)\b/i', $line)) {
continue;
}
// Extrait le nom de la colonne et sa définition SQL
if (preg_match('/^`?([a-zA-Z0-9_]+)`?\s+(.*)$/i', $line, $colMatch)) {
$colName = $colMatch[1];
$colDef = rtrim($colMatch[2], ',');
$columns[$colName] = $colDef;
}
}
$schema[$tableName] = $columns;
}
return $schema;
}
$expectedSchema = parseExpectedSchema($sqlContent);
echo "️ Modèle SQL chargé et analysé : <b>" . count($expectedSchema) . " tables</b> détectées.<hr>";
try {
$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 "<h2>Famille : {$family['name']} ($dbName)</h2>";
echo "<h2>🏡 Famille : {$family['name']} ($dbName)</h2>";
try {
$pdo = new PDO(
"mysql:host=$db_host;dbname=$dbName;charset=utf8mb4",
$db_user, $db_pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// ---------------------------------------------------------
// 1. UPDATE DE pf_people (Juste la date de naissance et l'état actif)
// 2. EXÉCUTION DU SQL (CRÉATION TABLES MANQUANTES + DONNÉES PAR DÉFAUT)
// ---------------------------------------------------------
$colCheck = $pdo->prepare("SELECT * FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'pf_people' AND COLUMN_NAME = 'birthdate'");
$colCheck->execute([$dbName]);
if ($colCheck->rowCount() === 0) {
$pdo->exec("ALTER TABLE pf_people
ADD COLUMN birthdate DATE DEFAULT NULL,
ADD COLUMN is_active TINYINT(1) DEFAULT 1
");
echo "✅ pf_people mis à jour (birthdate ajouté).<br>";
} else {
echo " pf_people déjà à jour.<br>";
}
// ---------------------------------------------------------
// 2. CRÉATION DES NOUVELLES TABLES (IF NOT EXISTS)
// ---------------------------------------------------------
// Comptes bancaires
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_bank_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
owner_person_id INT DEFAULT NULL,
account_type VARCHAR(50) DEFAULT 'savings',
is_default TINYINT(1) DEFAULT 0,
FOREIGN KEY (owner_person_id) REFERENCES pf_people(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// Catégories de budget
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_budget_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL,
label VARCHAR(100) NOT NULL,
type VARCHAR(50) NOT NULL,
color VARCHAR(20) DEFAULT '#ccc',
icon VARCHAR(20) DEFAULT '💰',
UNIQUE KEY (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// Types de congés
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_leave_types (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) NOT NULL,
label VARCHAR(100) NOT NULL,
default_allowance DECIMAL(5,2) DEFAULT 0,
reset_month INT DEFAULT 1,
allow_carry_over TINYINT(1) DEFAULT 0,
UNIQUE KEY (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// Fêtes (Cadeaux)
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_gift_occasions (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
month_date VARCHAR(5) DEFAULT NULL,
is_active TINYINT(1) DEFAULT 1,
UNIQUE KEY (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// Règles Cadeaux
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_gift_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
adult_person_id INT NOT NULL,
child_person_id INT NOT NULL,
occasion_id INT NOT NULL,
FOREIGN KEY (adult_person_id) REFERENCES pf_people(id) ON DELETE CASCADE,
FOREIGN KEY (child_person_id) REFERENCES pf_people(id) ON DELETE CASCADE,
FOREIGN KEY (occasion_id) REFERENCES pf_gift_occasions(id) ON DELETE CASCADE,
UNIQUE KEY adult_child_occ (adult_person_id, child_person_id, occasion_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
echo "✅ Nouvelles tables de configuration créées ou vérifiées.<br>";
// ---------------------------------------------------------
// 3. INJECTION DE DONNÉES PAR DÉFAUT (Pour ne rien casser)
// ---------------------------------------------------------
if ($pdo->query("SELECT COUNT(*) FROM pf_bank_accounts")->fetchColumn() == 0) {
$pdo->exec("INSERT INTO pf_bank_accounts (name, account_type, is_default) VALUES
('Compte Commun', 'checking', 1),
('Livret A Alex', 'savings', 0),
('Livret A Laia', 'savings', 0),
('Livret A Pol', 'savings', 0),
('Livret A Pep', 'savings', 0)
");
}
// Utilisation de INSERT IGNORE pour éviter les doublons tout en injectant les catégories manquantes
$pdo->exec("INSERT IGNORE INTO pf_budget_categories (code, label, type, color, icon) VALUES
('INCOME', 'Revenus', 'Income', '#22c55e', '💵'),
('FMCG', 'Alimentation', 'Expense', '#3b82f6', '🛒'),
('FUEL', 'Carburant', 'Expense', '#f59e0b', '⛽'),
('SCHOOL', 'École / Garde', 'Expense', '#a855f7', '🎒'),
('HEALTH', 'Santé', 'Expense', '#ef4444', '⚕️'),
('FIXED', 'Charges Fixes', 'Expense', '#ef4444', '🏢'),
('SAVINGS', 'Épargne & Projets', 'Expense', '#8b5cf6', '🐷'),
('AUTRES', 'Autres / Divers', 'Expense', '#64748b', '📁')
");
if ($pdo->query("SELECT COUNT(*) FROM pf_leave_types")->fetchColumn() == 0) {
$pdo->exec("INSERT INTO pf_leave_types (code, label, default_allowance, reset_month, allow_carry_over) VALUES
('CA', 'Congés Annuels', 25, 6, 1),
('JRA', 'Jours de Repos', 10, 1, 0),
('JA', 'Jour Anniversaire', 1, 1, 0)
");
}
if ($pdo->query("SELECT COUNT(*) FROM pf_gift_occasions")->fetchColumn() == 0) {
$pdo->exec("INSERT INTO pf_gift_occasions (code, name, month_date) VALUES
('NOEL', 'Noël', '12-25'),
('ROIS', 'Les Rois', '01-06'),
('ANNIV', 'Anniversaire', NULL)
");
}
echo "✅ Données de base injectées (si nécessaire).<br>";
// ---------------------------------------------------------
// 4. MIGRATION SPECIFIQUE : MODULE CALENDRIER (CONGÉS GRANULAIRES)
// ---------------------------------------------------------
$tableExists = $pdo->query("SHOW TABLES LIKE 'pf_person_leave_meta'")->rowCount() > 0;
if (!$tableExists) {
$pdo->exec("
CREATE TABLE pf_person_leave_meta (
id INT AUTO_INCREMENT PRIMARY KEY,
person_id INT NOT NULL,
leave_type VARCHAR(50) NOT NULL,
anniversary_date DATE NOT NULL,
UNIQUE KEY uq_person_leave (person_id, leave_type),
FOREIGN KEY (person_id) REFERENCES pf_people(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
echo "✅ Table pf_person_leave_meta créée (Nouvelle installation).<br>";
} else {
$checkColumn = $pdo->query("SHOW COLUMNS FROM pf_person_leave_meta LIKE 'leave_type'")->rowCount();
if ($checkColumn === 0) {
$pdo->exec("ALTER TABLE pf_person_leave_meta ADD COLUMN leave_type VARCHAR(50) NULL AFTER person_id");
$pdo->exec("UPDATE pf_person_leave_meta SET leave_type = 'CP' WHERE leave_type IS NULL");
$pdo->exec("ALTER TABLE pf_person_leave_meta MODIFY COLUMN leave_type VARCHAR(50) NOT NULL");
try {
$pdo->exec("ALTER TABLE pf_person_leave_meta DROP INDEX person_id");
} catch (\Throwable $e) {}
$pdo->exec("ALTER TABLE pf_person_leave_meta ADD UNIQUE KEY uq_person_leave (person_id, leave_type)");
echo "✅ Table pf_person_leave_meta mise à jour avec le type granulaire.<br>";
} else {
echo " Table pf_person_leave_meta déjà à jour.<br>";
}
}
// ---------------------------------------------------------
// 5. MIGRATION DES PARAMÈTRES DYNAMIQUES (pf_settings)
// ---------------------------------------------------------
$pdo->exec("
CREATE TABLE IF NOT EXISTS `pf_settings` (
`setting_key` varchar(50) NOT NULL,
`setting_value` text DEFAULT NULL,
`module` varchar(50) NOT NULL,
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`setting_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
$pdo->exec("
INSERT IGNORE INTO `pf_settings` (`setting_key`, `setting_value`, `module`) VALUES
('calendar_default_view', 'month', 'calendar'),
('calendar_first_day', '1', 'calendar'),
('calendar_working_hours', '08:00-19:00', 'calendar'),
('budget_start_day', '1', 'budget'),
('budget_default_tab', 'dépenses', 'budget'),
('travel_default_transport', 'car', 'voyage'),
('travel_default_fuel_price', '1.85', 'voyage'),
('gifts_hide_purchased', '0', 'cadeaux'),
('gifts_default_sort', 'person', 'cadeaux'),
('gifts_budget_alert', '500', 'cadeaux');
");
echo "✅ Table pf_settings créée et paramètres de base injectés.<br>";
try {
$pdo->exec("ALTER TABLE `pf_vehicles` ADD COLUMN `consumption` decimal(4,2) DEFAULT NULL COMMENT 'Consommation L/100km' AFTER `fuel_type`");
echo "✅ Colonne consumption ajoutée au Garage.<br>";
$pdo->exec($sqlContent);
echo "✅ Structure de base validée (les tables manquantes ont été créées).<br>";
} catch (PDOException $e) {
echo " pf_vehicles déjà à jour.<br>";
echo "⚠️ Avertissement SQL brut : " . $e->getMessage() . "<br>";
}
// ---------------------------------------------------------
// 6. MIGRATION DE L'HISTORIQUE CALENDRIER (FINI LE CODÉ EN DUR)
// 3. DIFFING (AJOUT DYNAMIQUE DES COLONNES MANQUANTES)
// ---------------------------------------------------------
$colsAdded = 0;
foreach ($expectedSchema as $table => $expectedCols) {
$stmtCol = $pdo->query("SHOW COLUMNS FROM `$table`");
$existingCols = $stmtCol->fetchAll(PDO::FETCH_COLUMN);
$stmtHelper = $pdo->query("SELECT id FROM pf_people WHERE role = 'helper' LIMIT 1");
$helperId = $stmtHelper->fetchColumn();
if ($helperId) {
$pdo->exec("UPDATE pf_events SET event_type = 'HELPER_OFF', person_id = $helperId WHERE event_type = 'OFF_CAROLE'");
$pdo->exec("UPDATE pf_events SET event_type = 'HELPER_EXTRA', person_id = $helperId WHERE event_type = 'EXTRA_OFF_CAROLE'");
echo "✅ Historique des absences Nounou migré dynamiquement.<br>";
foreach ($expectedCols as $colName => $colDef) {
if (!in_array($colName, $existingCols)) {
$pdo->exec("ALTER TABLE `$table` ADD COLUMN `$colName` $colDef");
echo " Nouvelle colonne ajoutée : <b>$table.$colName</b><br>";
$colsAdded++;
}
}
}
if ($colsAdded === 0) {
echo "✅ Toutes les colonnes de toutes les tables sont déjà à jour.<br>";
}
$stmtKid = $pdo->query("SELECT id FROM pf_people WHERE role IN ('child', 'enfant') ORDER BY id ASC LIMIT 1");
$kidId = $stmtKid->fetchColumn();
if ($kidId) {
$pdo->exec("UPDATE pf_events SET event_type = 'CHILD_SICK', person_id = $kidId WHERE event_type = 'PEP_SICK'");
echo "✅ Historique des maladies Enfant migré dynamiquement.<br>";
}
// ---------------------------------------------------------
// 7. MIGRATION DÉFINITIVE : STRUCTURE DU BUDGET
// 4. MIGRATION DES DONNÉES (TRANSFORMATIONS HISTORIQUES)
// ---------------------------------------------------------
echo "<i>🔄 Exécution des transformations de données héritées...</i><br>";
// A. Changement du type de la colonne (ENUM -> VARCHAR)
// A. Changement de types spécifiques (Car le parseur n'ajoute que les colonnes manquantes)
$pdo->exec("ALTER TABLE pf_budget_items MODIFY category VARCHAR(100) DEFAULT NULL");
// B. Migration de l'historique calendrier (Enfants & Helpers)
$helperId = $pdo->query("SELECT id FROM pf_people WHERE role = 'helper' LIMIT 1")->fetchColumn();
if ($helperId) {
$pdo->exec("UPDATE pf_events SET event_type = 'HELPER_OFF', person_id = $helperId WHERE event_type = 'OFF_CAROLE'");
$pdo->exec("UPDATE pf_events SET event_type = 'HELPER_EXTRA', person_id = $helperId WHERE event_type = 'EXTRA_OFF_CAROLE'");
}
$kidId = $pdo->query("SELECT id FROM pf_people WHERE role IN ('child', 'enfant') ORDER BY id ASC LIMIT 1")->fetchColumn();
if ($kidId) {
$pdo->exec("UPDATE pf_events SET event_type = 'CHILD_SICK', person_id = $kidId WHERE event_type = 'PEP_SICK'");
}
// B. MAPPING INTELLIGENT DES DONNEES EXISTANTES (Nettoyage de l'ancienne logique)
// C. Mapping Intelligent du Budget (is_estimate)
$itemsMigres = 0;
// 1. Les charges fixes (is_estimate = 0) deviennent FIXED
$itemsMigres += $pdo->exec("UPDATE pf_budget_items SET category = 'FIXED' WHERE category = 'expense' AND is_estimate = 0");
// 2. Les estimations (is_estimate = 1) sont mappées selon leur nom ou mots clés (hack historique)
$itemsMigres += $pdo->exec("UPDATE pf_budget_items SET category = 'FUEL' WHERE category = 'expense' AND is_estimate = 1 AND (mapping_keywords LIKE '%ESSENCE%' OR name LIKE '%gasolina%')");
$itemsMigres += $pdo->exec("UPDATE pf_budget_items SET category = 'SCHOOL' WHERE category = 'expense' AND is_estimate = 1 AND (mapping_keywords LIKE '%ESCOLA%' OR mapping_keywords LIKE '%PARASCOL%' OR name LIKE '%escola%')");
$itemsMigres += $pdo->exec("UPDATE pf_budget_items SET category = 'FMCG' WHERE category = 'expense' AND is_estimate = 1 AND (mapping_keywords LIKE '%FMCG%' OR name LIKE '%F&B%')");
// 3. On sécurise les éventuelles estimations restantes dans AUTRES
$itemsMigres += $pdo->exec("UPDATE pf_budget_items SET category = 'AUTRES' WHERE category = 'expense'");
// 4. On sécurise les anciens revenus
$itemsMigres += $pdo->exec("UPDATE pf_budget_items SET category = 'INCOME' WHERE category = 'income'");
// C. Nettoyage des virgules orphelines dans mapping_keywords (suppression des vieux hacks)
// D. Nettoyage des virgules orphelines dans mapping_keywords
$budgetCodes = ['INCOME', 'FMCG', 'FUEL', 'SCHOOL', 'HEALTH', 'FIXED', 'SAVINGS', 'AUTRES'];
foreach ($budgetCodes as $code) {
$pdo->exec("UPDATE pf_budget_items SET mapping_keywords = REPLACE(mapping_keywords, '$code', '') WHERE mapping_keywords LIKE '%$code%'");
}
$pdo->exec("UPDATE pf_budget_items SET mapping_keywords = TRIM(BOTH ',' FROM REPLACE(REPLACE(mapping_keywords, ' ', ''), ',,', ','))");
// D. Migration de l'historique des dépenses et des règles (Anciens libellés FR/EN -> Nouveaux Codes Dynamiques)
// E. Migration de l'historique des dépenses et des règles d'import
$budgetMapping = [
'Income' => 'INCOME',
'FMCG' => 'FMCG',
'Essence' => 'FUEL',
'School' => 'SCHOOL',
'Frais' => 'FIXED',
'LivretA' => 'SAVINGS',
'Autres' => 'AUTRES'
];
$stmtExp = $pdo->prepare("UPDATE pf_expenses SET category = ? WHERE category = ?");
$stmtRules = $pdo->prepare("UPDATE pf_import_rules SET category = ? WHERE category = ?");
$expCount = 0;
$rulesCount = 0;
foreach ($budgetMapping as $old => $newCode) {
$stmtExp->execute([$newCode, $old]);
$expCount += $stmtExp->rowCount();
$stmtRules->execute([$newCode, $old]);
$rulesCount += $stmtRules->rowCount();
}
echo "✅ Budget Structure : Table pf_budget_items passée en VARCHAR. $itemsMigres prévisions recatégorisées. Historique migré ($expCount dépenses et $rulesCount règles mises à jour).<br>";
echo "✅ Transformations Data terminées ($itemsMigres prévisions remappées).<hr>";
} catch (PDOException $e) {
echo "❌ Erreur sur la base $dbName : " . $e->getMessage() . "<br>";
echo "❌ Erreur sur la base $dbName : " . $e->getMessage() . "<hr>";
}
} // FIN DE LA BOUCLE FOREACH FAMILLES
} // FIN DE LA BOUCLE FOREACH
try {
// Vérification si la colonne csv_mapping existe déjà dans pf_foyer_settings
$checkCol = $pdo->query("SHOW COLUMNS FROM pf_foyer_settings LIKE 'csv_mapping'")->fetch();
if (!$checkCol) {
$pdo->exec("ALTER TABLE pf_foyer_settings ADD COLUMN csv_mapping TEXT NULL");
echo "✅ Migration : Colonne 'csv_mapping' ajoutée à la table 'pf_foyer_settings'.\n";
} else {
echo "️ Migration : La colonne 'csv_mapping' existe déjà.\n";
}
} catch (PDOException $e) {
echo "❌ Erreur lors de la migration : " . $e->getMessage() . "\n";
}
echo "<h1>🎉 Migration terminée avec succès !</h1>";
echo "<h1>🎉 Synchronisation et Migration terminées avec succès !</h1>";
} catch (Exception $e) {
die("❌ Erreur fatale Meta DB : " . $e->getMessage());
}
?>
+542 -232
View File
@@ -37,6 +37,7 @@
letter-spacing: -0.025em;
}
/* Thèmes des tableaux */
.nens-title.theme-pol {
color: #1e3a8a !important;
background: #eaf2ff !important;
@@ -147,6 +148,7 @@
z-index: 10 !important;
pointer-events: auto !important;
}
.month-actions {
display: flex;
gap: 4px;
@@ -261,13 +263,13 @@ td:hover .btn-cell-delete {
padding: 18px 16px;
border-top: 1px solid #e2e8f0;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
@@ -373,7 +375,7 @@ input[type="number"] {
gap: 10px;
}
/* Configuration Chromatique Parent 1 */
/* Configuration Chromatique Parent 1 & 2 */
.txt-p1,
.txt-alex {
color: color-mix(in srgb, var(--p1-main, #0891b2) 85%, #000000) !important;
@@ -404,7 +406,6 @@ input.txt-p1:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--p1-main, #0891b2) 10%, #ffffff) !important;
}
/* Configuration Chromatique Parent 2 */
.txt-p2 {
color: color-mix(in srgb, var(--p2-main, #f59e0b) 85%, #000000) !important;
font-weight: bold;
@@ -503,8 +504,6 @@ input.txt-p2:focus {
color: var(--text-main) !important;
border-left: 4px solid var(--p1-main, #0891b2) !important;
}
/* Parent 2 (Ligne 2, ex: Laia) */
.prev-salary-table tbody tr:nth-child(2) td:first-child {
text-align: left !important;
font-weight: bold !important;
@@ -762,7 +761,6 @@ tr:hover .row-actions {
gap: 4px;
}
/* --- Style de base du bouton de validation (Générique) --- */
.recap-table .pf-btn.btn-small {
background: #ffffff !important;
font-size: 0.7rem !important;
@@ -775,8 +773,6 @@ tr:hover .row-actions {
transform: none !important;
transition: all 0.2s ease;
}
/* Couleurs dynamiques adaptées au Parent 1 (ex: Féfé) */
.col-p1 .pf-btn.btn-small {
color: var(--p1-main, #0891b2) !important;
border: 1px solid var(--p1-main, #0891b2) !important;
@@ -788,8 +784,6 @@ tr:hover .row-actions {
#ffffff
) !important;
}
/* Couleurs dynamiques adaptées au Parent 2 (ex: Laia) */
.col-p2 .pf-btn.btn-small {
color: var(--p2-main, #f59e0b) !important;
border: 1px solid var(--p2-main, #f59e0b) !important;
@@ -802,7 +796,6 @@ tr:hover .row-actions {
) !important;
}
/* Grand Totaux Structuraux */
.row-grand-total td {
background-color: #1e293b;
color: white;
@@ -1094,7 +1087,544 @@ body.sum-mode-active .sum-target {
transition: background-color 0.2s;
}
/* --- 13. RESPONSIVE MOBILE BUDGET --- */
/* --- 13. MODALE PARAMÈTRES BUDGET (Design System HouseHub) --- */
.budget-settings-backdrop {
display: none;
position: fixed;
inset: 0;
z-index: 200;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
padding: 1rem;
}
.budget-settings-backdrop.show {
display: flex;
}
.budget-settings-modal {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 14px;
width: 100%;
max-width: 800px;
height: 80vh;
max-height: 700px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
animation: modalIn 0.18s ease;
}
.bs-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.bs-header h3 {
margin: 0;
font-size: 1.1rem;
color: var(--text-main);
}
.bs-close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--text-muted);
border-radius: 6px;
padding: 0 0.5rem;
}
.bs-close:hover {
background: var(--bg-page);
color: var(--text-main);
}
.bs-layout {
display: flex;
flex: 1;
overflow: hidden;
}
.bs-sidebar {
width: 200px;
border-right: 1px solid var(--border-light);
background: var(--bg-page);
display: flex;
flex-direction: column;
padding: 0.75rem;
gap: 0.4rem;
overflow-y: auto;
}
.bs-tab-btn {
text-align: left;
padding: 0.6rem 1rem;
border-radius: 8px;
border: none;
background: transparent;
color: var(--text-muted);
font-weight: 600;
cursor: pointer;
transition: 0.15s;
font-size: 0.9rem;
}
.bs-tab-btn:hover {
background: var(--border-light);
color: var(--text-main);
}
.bs-tab-btn.active {
background: var(--text-main);
color: var(--bg-panel);
}
.bs-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
background: var(--bg-panel);
}
.bs-pane {
display: none;
}
.bs-pane.active {
display: block;
animation: fadeIn 0.2s ease;
}
/* Utilitaires et Formulaires Paramètres */
.bs-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--border-light);
border-radius: 8px;
margin-bottom: 0.5rem;
background: var(--bg-panel);
}
.bs-list-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.bs-section-title {
font-size: 1.1rem;
color: var(--text-main);
margin-top: 0;
margin-bottom: 1rem;
}
.bs-section-subtitle {
font-size: 1rem;
margin-top: 0;
margin-bottom: 1rem;
}
.bs-section-subtitle.bordered {
margin: 0 0 15px 0;
border-bottom: 1px solid var(--border-light);
padding-bottom: 5px;
font-size: 0.95rem;
}
.bs-form-inline {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.bs-form-inline.align-center {
align-items: center;
}
.bs-input-flex {
flex: 1;
min-width: 150px;
padding: 0.4rem 0.75rem;
}
.bs-input-fixed {
width: 130px;
padding: 0.4rem 0.75rem;
}
.bs-input-icon {
width: 65px;
padding: 0.4rem 0.2rem;
font-size: 1.1rem;
cursor: pointer;
text-align: center;
}
.bs-input-color {
width: 38px;
height: 38px;
padding: 0;
border: 1px solid var(--border-light);
border-radius: 6px;
cursor: pointer;
}
.bs-pane-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.2rem;
border-bottom: 1px solid var(--border-light);
padding-bottom: 0.5rem;
}
.bs-currency-selector {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
.bs-currency-select {
width: 75px;
padding: 3px 6px;
font-size: 0.8rem;
height: auto;
cursor: pointer;
background: var(--bg-page);
}
.bs-divider {
margin: 1.5rem 0;
border: 0;
border-bottom: 1px solid var(--border-light);
}
.bs-badge-default {
font-size: 0.7em;
background: var(--success);
color: white;
padding: 2px 6px;
border-radius: 4px;
margin-left: 8px;
}
/* Onboarding Checklist */
.bs-onboarding-card {
background: #fffbeb;
border-left: 4px solid #f59e0b;
padding: 15px;
margin-bottom: 25px;
border-radius: 8px;
}
.bs-onboarding-title {
margin: 0 0 10px 0;
color: #b45309;
display: flex;
align-items: center;
gap: 8px;
font-size: 1.1rem;
}
.bs-onboarding-desc {
margin: 0 0 10px 0;
font-size: 0.85rem;
color: #d97706;
}
.bs-onboarding-list {
margin: 0;
padding-left: 20px;
color: #b45309;
font-size: 0.9rem;
line-height: 1.8;
}
.bs-onboarding-list li {
margin-bottom: 4px;
}
.bs-onboarding-list strong {
font-size: 1.1rem;
margin-right: 5px;
}
.bs-onboarding-link {
color: #b45309;
text-decoration: underline;
font-weight: bold;
cursor: pointer;
}
/* Affichage Tag Cloud Règles */
.bs-rule-search {
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 8px;
border: 1px solid var(--border-light);
background: var(--bg-page);
margin-bottom: 1rem;
color: var(--text-main);
font-family: inherit;
}
.bs-accordion-summary {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.bs-accordion-count {
font-size: 0.8rem;
background: var(--border-light);
padding: 2px 8px;
border-radius: 10px;
color: var(--text-muted);
font-weight: normal;
margin-left: auto;
margin-right: 10px;
}
.bs-rule-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
padding: 0.5rem 0;
}
.bs-rule-tag {
background: var(--bg-page);
border: 1px solid var(--border-light);
padding: 3px 10px;
border-radius: 14px;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 6px;
transition: 0.15s;
}
.bs-rule-tag:hover {
border-color: var(--danger);
background: #fef2f2;
}
.bs-rule-tag-del {
color: var(--text-muted);
cursor: pointer;
border: none;
background: none;
padding: 0;
font-size: 1rem;
line-height: 1;
margin-top: -2px;
}
.bs-rule-tag:hover .bs-rule-tag-del {
color: var(--danger);
}
/* CSV & Utils */
.bs-desc {
margin-top: 0;
margin-bottom: 15px;
color: var(--text-muted);
font-size: 0.85rem;
}
.bs-csv-dropzone {
border: 2px dashed var(--border-light);
border-radius: var(--radius);
padding: 30px;
text-align: center;
cursor: pointer;
margin-bottom: 25px;
transition: all 0.2s;
background: var(--bg-page);
}
.bs-csv-dropzone:hover {
border-color: var(--primary);
background: rgba(37, 99, 235, 0.05);
}
.bs-csv-icon {
font-size: 2rem;
margin-bottom: 10px;
}
.bs-csv-title {
margin: 0 0 5px 0;
font-size: 1.1rem;
}
.bs-csv-subtitle {
margin: 0;
color: var(--text-muted);
font-size: 0.85rem;
}
.bs-csv-preview {
display: none;
background: var(--bg-page);
padding: 15px;
border-radius: var(--radius);
border: 1px dashed var(--primary);
margin-bottom: 25px;
}
.bs-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 15px;
}
.bs-text-muted-normal {
color: var(--text-muted);
font-weight: normal;
}
.bs-card-gray {
background: var(--bg-panel);
padding: 10px;
border-radius: var(--radius);
border: 1px solid var(--border-light);
margin-bottom: 15px;
}
.bs-input-mb {
margin-bottom: 10px;
}
.bs-text-right {
text-align: right;
}
.bs-csv-table-wrapper {
overflow-x: auto;
max-width: 100%;
border: 1px solid var(--border-light);
border-radius: 8px;
background: white;
}
.bs-csv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
text-align: left;
white-space: nowrap;
}
.bs-csv-thead {
background: var(--bg-page);
border-bottom: 2px solid var(--border-light);
}
.bs-csv-th {
padding: 10px 15px;
font-weight: 600;
color: var(--text-main);
border-right: 1px solid var(--border-light);
}
.bs-csv-th-index {
font-size: 0.75rem;
color: var(--primary);
margin-bottom: 2px;
}
.bs-csv-td-empty {
padding: 15px;
text-align: center;
color: var(--text-muted);
font-style: italic;
}
.bs-csv-tr {
border-bottom: 1px solid #e2e8f0;
}
.bs-csv-td {
padding: 10px 15px;
color: var(--text-muted);
border-right: 1px dashed #e2e8f0;
}
/* Ajouts Structure Global Budget */
.budget-hero {
text-align: center;
margin-bottom: 30px;
}
.budget-title-group {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin-bottom: 20px;
}
.budget-title-group h1 {
margin: 0;
}
.btn-settings-gear {
position: relative;
}
.budget-error-msg {
text-align: center;
padding: 50px;
color: var(--danger);
}
@keyframes pulse-warning {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
/* Le bouton engrenage devient un simple conteneur fixe */
.btn-header-settings {
position: relative;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px;
text-decoration: none;
color: inherit;
}
/* C'est uniquement l'icône à l'intérieur qui tourne ! */
.gear-icon-anim {
display: inline-block;
transition: transform 0.4s ease-in-out;
}
.btn-header-settings:hover .gear-icon-anim {
transform: rotate(90deg);
}
.alert-dot {
display: flex;
align-items: center;
justify-content: center;
background: var(--danger, #ef4444);
color: white;
border-radius: 50%;
width: 18px;
height: 18px;
font-size: 0.85rem;
font-weight: bold;
position: absolute;
top: -4px;
right: -4px;
animation: pulse-warning 2s infinite;
border: 2px solid white;
line-height: 1;
}
.alert-dot-inline {
position: relative;
top: auto;
right: auto;
display: inline-flex;
margin-left: 6px;
width: 16px;
height: 16px;
font-size: 0.75rem;
border-width: 1px;
animation: none; /* Pas de clignotement dans le menu pour ne pas agresser l'œil */
}
/* Responsive Modale Paramètres */
@media (max-width: 650px) {
.bs-layout {
flex-direction: column;
}
.bs-sidebar {
width: 100%;
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border-light);
padding: 0.5rem;
white-space: nowrap;
}
.bs-tab-btn {
flex-shrink: 0;
}
.bs-content {
padding: 1rem;
}
}
/* Responsive Mobile Budget */
@media (max-width: 768px) {
.budget-view {
padding: 10px !important;
@@ -1268,223 +1798,3 @@ body.sum-mode-active .sum-target {
padding: 12px 10px;
}
}
/* ==========================================================================
MODALE PARAMÈTRES BUDGET (Design System HouseHub)
========================================================================== */
.budget-settings-backdrop {
display: none;
position: fixed;
inset: 0;
z-index: 200;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
padding: 1rem;
}
.budget-settings-backdrop.show {
display: flex;
}
.budget-settings-modal {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 14px;
width: 100%;
max-width: 800px;
height: 80vh;
max-height: 700px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
animation: modalIn 0.18s ease;
}
.bs-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.bs-header h3 {
margin: 0;
font-size: 1.1rem;
color: var(--text-main);
}
.bs-close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--text-muted);
border-radius: 6px;
padding: 0 0.5rem;
}
.bs-close:hover {
background: var(--bg-page);
color: var(--text-main);
}
.bs-layout {
display: flex;
flex: 1;
overflow: hidden;
}
.bs-sidebar {
width: 200px;
border-right: 1px solid var(--border-light);
background: var(--bg-page);
display: flex;
flex-direction: column;
padding: 0.75rem;
gap: 0.4rem;
overflow-y: auto;
}
.bs-tab-btn {
text-align: left;
padding: 0.6rem 1rem;
border-radius: 8px;
border: none;
background: transparent;
color: var(--text-muted);
font-weight: 600;
cursor: pointer;
transition: 0.15s;
font-size: 0.9rem;
}
.bs-tab-btn:hover {
background: var(--border-light);
color: var(--text-main);
}
.bs-tab-btn.active {
background: var(--text-main);
color: var(--bg-panel);
}
.bs-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
background: var(--bg-panel);
}
.bs-pane {
display: none;
}
.bs-pane.active {
display: block;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Mobile Responsive */
@media (max-width: 650px) {
.bs-layout {
flex-direction: column;
}
.bs-sidebar {
width: 100%;
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border-light);
padding: 0.5rem;
white-space: nowrap;
}
.bs-tab-btn {
flex-shrink: 0;
}
.bs-content {
padding: 1rem;
}
}
/* Utilitaires pour les listes de paramètres */
.bs-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--border-light);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.bs-section-title {
font-size: 1.1rem;
color: var(--text-main);
margin-top: 0;
margin-bottom: 1rem;
}
/* --- AFFICHAGE SMART DES RÈGLES (TAG CLOUD) --- */
.bs-rule-search {
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 8px;
border: 1px solid var(--border-light);
background: var(--bg-page);
margin-bottom: 1rem;
color: var(--text-main);
font-family: inherit;
}
.bs-rule-group {
margin-bottom: 1.2rem;
}
.bs-rule-group-title {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border-light);
padding-bottom: 4px;
}
.bs-rule-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.bs-rule-tag {
background: var(--bg-page);
border: 1px solid var(--border-light);
padding: 3px 10px;
border-radius: 14px;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 6px;
transition: 0.15s;
}
.bs-rule-tag:hover {
border-color: var(--danger);
background: #fef2f2;
}
.bs-rule-tag-del {
color: var(--text-muted);
cursor: pointer;
border: none;
background: none;
padding: 0;
font-size: 1rem;
line-height: 1;
margin-top: -2px;
}
.bs-rule-tag:hover .bs-rule-tag-del {
color: var(--danger);
}
+10 -1
View File
@@ -29,7 +29,16 @@ try {
// 4. Salaires (Année en cours)
$currentYear = (int)date('Y');
$salaries = $pdo->query("SELECT * FROM pf_salary_config WHERE year = $currentYear ORDER BY person ASC")->fetchAll(PDO::FETCH_ASSOC);
$salaries = $pdo->query("
SELECT p.name as person,
COALESCE(s.salary, 0) as salary,
COALESCE(s.mensualite, 0) as mensualite,
s.id as id
FROM pf_people p
LEFT JOIN pf_salary_config s ON p.name = s.person AND s.year = $currentYear
WHERE p.role NOT IN ('enfant', 'nounou') AND p.is_active = 1
ORDER BY p.name ASC
")->fetchAll(PDO::FETCH_ASSOC);
// 5. Paramètres Foyer (Devise + Mapping CSV)
$foyerData = $pdo->query("SELECT currency, csv_mapping FROM pf_foyer_settings LIMIT 1")->fetch(PDO::FETCH_ASSOC);
+150 -132
View File
@@ -1,15 +1,18 @@
<?php
// modules/budget/views/budget_prev.php
// 1. Chargement dynamique des parents
$stmtPeople = $pdo->query("SELECT id, name, user_id, role, color FROM pf_people WHERE role = 'parent' ORDER BY id ASC");
// 1. Chargement dynamique des ADULTES de la famille
$stmtPeople = $pdo->query("SELECT id, name, user_id, role, color FROM pf_people WHERE role NOT IN ('enfant', 'nounou') AND is_active = 1 ORDER BY id ASC");
$budgetParents = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
$p1_name = $budgetParents[0]['name'] ?? 'Parent 1';
$p2_name = $budgetParents[1]['name'] ?? 'Parent 2';
// Sécurité : au cas où aucun adulte n'est trouvé, on évite un crash
if (empty($budgetParents)) {
$budgetParents[] = ['id' => 0, 'name' => 'Utilisateur', 'color' => '#0891b2'];
}
$currentYear = date('Y');
// Cartographie dynamique
// Cartographie dynamique (Supporte 1, 2 ou N adultes)
$parentMapping = [];
foreach ($budgetParents as $index => $parent) {
$num = $index + 1;
@@ -21,6 +24,10 @@ foreach ($budgetParents as $index => $parent) {
];
}
// 1.5 Récupération des VRAIS comptes bancaires créés dans les paramètres
$stmtAccounts = $pdo->query("SELECT name FROM pf_bank_accounts ORDER BY is_default DESC, name ASC");
$bankAccounts = $stmtAccounts->fetchAll(PDO::FETCH_COLUMN);
// 2. Récupération Config Salaires
$salaryConfig = [];
$stmt = $pdo->prepare("SELECT * FROM pf_salary_config WHERE year = ?");
@@ -29,7 +36,7 @@ while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$salaryConfig[$row['person']] = $row;
}
// Sécurité profils de salaire vides
// Sécurité profils de salaire vides (Garantit l'affichage à 0 même si non configuré)
foreach ($parentMapping as $map) {
if (!isset($salaryConfig[$map['name']])) {
$salaryConfig[$map['name']] = ['salary'=>0, 'mensualite'=>0, 'frais_func'=>0, 'eco_perso'=>0, 'eco_family'=>0];
@@ -103,21 +110,28 @@ function getTranslatedMonthName($dateString) {
return tr('month_' . $m) . ' ' . $y;
}
// --- 🧠 PREPARATION DATA TRICOUNT / AVANCES ---
// --- 🧠 PREPARATION DATA TRICOUNT / AVANCES (Dynamique) ---
$stmtAdvancesList = $pdo->query("SELECT * FROM pf_advances WHERE is_resolved = 0 ORDER BY advance_date DESC");
$activeAdvances = $stmtAdvancesList->fetchAll(PDO::FETCH_ASSOC);
$advTotal = [$p1_name => 0, $p2_name => 0];
$livretTotal = [$p1_name => 0, $p2_name => 0];
$advTotal = [];
$livretTotal = [];
$labelsCC = [];
$labelsLivret = [];
$labelsCC = [$p1_name => [], $p2_name => []];
$labelsLivret = [$p1_name => [], $p2_name => []];
foreach ($parentMapping as $map) {
$advTotal[$map['name']] = 0;
$livretTotal[$map['name']] = 0;
$labelsCC[$map['name']] = [];
$labelsLivret[$map['name']] = [];
}
foreach ($activeAdvances as $adv) {
$p = $adv['payer'];
$amt = (float)$adv['amount'];
$labelStr = htmlspecialchars($adv['description']) . ' (' . number_format($amt, 0, ',', ' ') . '€)';
// Si un ancien nom traîne en base, on l'initialise
if (!isset($advTotal[$p])) {
$advTotal[$p] = 0; $livretTotal[$p] = 0;
$labelsCC[$p] = []; $labelsLivret[$p] = [];
@@ -132,10 +146,16 @@ foreach ($activeAdvances as $adv) {
}
}
$balanceDiff = abs(($advTotal[$p1_name] ?? 0) - ($advTotal[$p2_name] ?? 0));
// Calcul de la dette croisée (Valable surtout si 2 parents)
$balanceDiff = 0;
$owedTo = '';
if (($advTotal[$p1_name] ?? 0) > ($advTotal[$p2_name] ?? 0)) $owedTo = $p1_name;
elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_name;
if (count($parentMapping) >= 2) {
$p1 = $parentMapping[0]['name'];
$p2 = $parentMapping[1]['name'];
$balanceDiff = abs(($advTotal[$p1] ?? 0) - ($advTotal[$p2] ?? 0));
if (($advTotal[$p1] ?? 0) > ($advTotal[$p2] ?? 0)) $owedTo = $p1;
elseif (($advTotal[$p2] ?? 0) > ($advTotal[$p1] ?? 0)) $owedTo = $p2;
}
?>
@@ -261,9 +281,10 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
$rowClass = $isIndicative ? 'row-indicative' : '';
$inputClass = $isIndicative ? 'ignore-calc' : '';
// Retrocompatibilité pour les anciens noms d'économies hardcodés
$catDisplayName = $cat['name'];
if ($catDisplayName === 'Eco P1') $catDisplayName = 'Eco ' . $p1_name;
if ($catDisplayName === 'Eco P2') $catDisplayName = 'Eco ' . $p2_name;
if (isset($parentMapping[0]) && $catDisplayName === 'Eco P1') $catDisplayName = 'Eco ' . $parentMapping[0]['name'];
if (isset($parentMapping[1]) && $catDisplayName === 'Eco P2') $catDisplayName = 'Eco ' . $parentMapping[1]['name'];
?>
<tr class="<?= $rowClass ?>">
<td class="col-sticky">
@@ -323,6 +344,7 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
</div>
</div>
<?php if (count($parentMapping) > 1): ?>
<div style="margin: 30px 0; background: var(--bg-panel); padding: 24px; border-radius: var(--radius); border: 1px solid var(--border-light); box-shadow: var(--shadow);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px;">
<h3 style="margin: 0; font-size: 1.3rem; font-weight: 800;"><?= tr('bud_adv_title') ?></h3>
@@ -332,57 +354,39 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
</div>
<div style="display: flex; gap: 20px; margin-bottom: 24px; flex-wrap: wrap;">
<div style="flex: 1; min-width: 240px; background: rgba(8, 145, 178, 0.06); border: 1px solid rgba(8, 145, 178, 0.2); padding: 18px; border-radius: 12px;">
<div style="font-size: 0.85rem; color: #0891b2; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;"><?= sprintf(tr('bud_adv_has_advanced'), htmlspecialchars($p1_name)) ?></div>
<?php foreach ($parentMapping as $idx => $map):
$pName = $map['name'];
$bgClass = ($idx === 0) ? 'rgba(8, 145, 178, 0.06)' : 'rgba(217, 119, 6, 0.06)';
$bdClass = ($idx === 0) ? 'rgba(8, 145, 178, 0.2)' : 'rgba(217, 119, 6, 0.2)';
$colorMain = ($idx === 0) ? '#0891b2' : '#d97706';
$colorDark = ($idx === 0) ? '#164e63' : '#78350f';
?>
<div style="flex: 1; min-width: 240px; background: <?= $bgClass ?>; border: 1px solid <?= $bdClass ?>; padding: 18px; border-radius: 12px;">
<div style="font-size: 0.85rem; color: <?= $colorMain ?>; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;"><?= sprintf(tr('bud_adv_has_advanced'), htmlspecialchars($pName)) ?></div>
<div style="margin-top: 10px;">
<div style="display: flex; align-items: baseline; gap: 6px;">
<span style="font-size: 1.5rem; font-weight: 800; color: #164e63; font-family: monospace;"><?= number_format($advTotal[$p1_name] ?? 0, 2, ',', ' ') ?> €</span>
<span style="font-size: 1.5rem; font-weight: 800; color: <?= $colorDark ?>; font-family: monospace;"><?= number_format($advTotal[$pName] ?? 0, 2, ',', ' ') ?> €</span>
</div>
<small style="color: var(--text-muted); font-size: 0.75rem; font-weight: 500;"><?= tr('bud_adv_cc_label') ?></small>
<?php if (!empty($labelsCC[$p1_name])): ?>
<div style="font-size: 0.75rem; color: #0e7490; margin-top: 6px; line-height: 1.4; font-style: italic;">
<?= implode(', ', $labelsCC[$p1_name]) ?>
<?php if (!empty($labelsCC[$pName])): ?>
<div style="font-size: 0.75rem; color: <?= $colorMain ?>; margin-top: 6px; line-height: 1.4; font-style: italic;">
<?= implode(', ', $labelsCC[$pName]) ?>
</div>
<?php endif; ?>
</div>
<?php if(($livretTotal[$p1_name] ?? 0) > 0): ?>
<div style="margin-top: 14px; padding-top: 10px; border-top: 1px dashed rgba(8, 145, 178, 0.3);">
<div style="font-size: 1.2rem; font-weight: 800; color: #4338ca; font-family: monospace;">+ <?= number_format($livretTotal[$p1_name], 2, ',', ' ') ?> €</div>
<small style="color: #4338ca; font-size: 0.75rem; font-weight: 600;"><?= tr('bud_adv_livret_label') ?></small>
<?php if (!empty($labelsLivret[$p1_name])): ?>
<div style="font-size: 0.75rem; color: #3730a3; margin-top: 4px; line-height: 1.4;">
<?= implode(', ', $labelsLivret[$p1_name]) ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<div style="flex: 1; min-width: 240px; background: rgba(217, 119, 6, 0.06); border: 1px solid rgba(217, 119, 6, 0.2); padding: 18px; border-radius: 12px;">
<div style="font-size: 0.85rem; color: #d97706; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;"><?= sprintf(tr('bud_adv_has_advanced'), htmlspecialchars($p2_name)) ?></div>
<div style="margin-top: 10px;">
<div style="display: flex; align-items: baseline; gap: 6px;">
<span style="font-size: 1.5rem; font-weight: 800; color: #78350f; font-family: monospace;"><?= number_format($advTotal[$p2_name] ?? 0, 2, ',', ' ') ?> €</span>
</div>
<small style="color: var(--text-muted); font-size: 0.75rem; font-weight: 500;"><?= tr('bud_adv_cc_label') ?></small>
<?php if (!empty($labelsCC[$p2_name])): ?>
<div style="font-size: 0.75rem; color: #b45309; margin-top: 6px; line-height: 1.4; font-style: italic;">
<?= implode(', ', $labelsCC[$p2_name]) ?>
</div>
<?php endif; ?>
</div>
<?php if(($livretTotal[$p2_name] ?? 0) > 0): ?>
<div style="margin-top: 14px; padding-top: 10px; border-top: 1px dashed rgba(217, 119, 6, 0.3);">
<div style="font-size: 1.2rem; font-weight: 800; color: #b45309; font-family: monospace;">+ <?= number_format($livretTotal[$p2_name], 2, ',', ' ') ?> €</div>
<small style="color: #b45309; font-size: 0.75rem; font-weight: 600;"><?= tr('bud_adv_livret_label') ?></small>
<?php if (!empty($labelsLivret[$p2_name])): ?>
<div style="font-size: 0.75rem; color: #92400e; margin-top: 4px; line-height: 1.4;">
<?= implode(', ', $labelsLivret[$p2_name]) ?>
<?php if(($livretTotal[$pName] ?? 0) > 0): ?>
<div style="margin-top: 14px; padding-top: 10px; border-top: 1px dashed <?= $bdClass ?>;">
<div style="font-size: 1.2rem; font-weight: 800; color: <?= $colorDark ?>; font-family: monospace;">+ <?= number_format($livretTotal[$pName], 2, ',', ' ') ?> €</div>
<small style="color: <?= $colorDark ?>; font-size: 0.75rem; font-weight: 600;"><?= tr('bud_adv_livret_label') ?></small>
<?php if (!empty($labelsLivret[$pName])): ?>
<div style="font-size: 0.75rem; color: <?= $colorDark ?>; margin-top: 4px; line-height: 1.4;">
<?= implode(', ', $labelsLivret[$pName]) ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<div style="flex: 1; min-width: 240px; background: var(--bg-page); border: 1px solid var(--border-light); padding: 18px; border-radius: 12px; display: flex; flex-direction: column; justify-content: center;">
<div style="font-size: 0.75rem; color: var(--text-muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px;">
@@ -411,10 +415,19 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
</tr>
</thead>
<tbody>
<?php foreach ($activeAdvances as $adv): ?>
<?php foreach ($activeAdvances as $adv):
// Attribution de la couleur selon l'utilisateur
$colorP = '#64748b'; // Defaut
foreach($parentMapping as $idx => $m) {
if($m['name'] === $adv['payer']) {
$colorP = ($idx === 0) ? '#0891b2' : '#d97706';
break;
}
}
?>
<tr>
<td style="color: var(--text-muted);"><?= date('d/m/Y', strtotime($adv['advance_date'])) ?></td>
<td style="font-weight: 700; color: <?= $adv['payer'] === $p1_name ? '#0891b2' : '#d97706' ?>;"><?= htmlspecialchars($adv['payer']) ?></td>
<td style="font-weight: 700; color: <?= $colorP ?>;"><?= htmlspecialchars($adv['payer']) ?></td>
<td>
<?= htmlspecialchars($adv['description']) ?>
<?php if ($adv['from_savings']): ?>
@@ -441,11 +454,19 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
</div>
<?php endif; ?>
</div>
<?php endif; // Fin du Tricount conditionnel ?>
<?php
$focusMonth = $months[0];
$targetsOrder = ['vers commune', 'vers L.Pol', 'vers L.Pep', 'vers L.Perso'];
// Génération dynamique des cibles basées UNIQUEMENT sur les comptes bancaires
$targetsOrder = [];
foreach ($bankAccounts as $accName) {
$targetsOrder[] = 'vers ' . $accName;
}
// On ajoute quand même les cibles historiques enregistrées dans les catégories
// (pour ne pas casser l'affichage si on supprime un compte plus tard)
$allTargets = $targetsOrder;
foreach($cats as $c) {
$t = trim($c['transfer_dest'] ?? '');
@@ -506,33 +527,38 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
</div>
</div>
<div id="addCatModal" class="pf-modal">
<div class="pf-modal-content">
<div class="prev-header-left">
<h3 class="pf-modal-title"><?= tr('bud_prev_new_line_title') ?></h3>
<button onclick="document.getElementById('addCatModal').style.display='none'; document.body.classList.remove('no-scroll');">&times;</button>
<div id="addCatModal" class="pf-modal" onclick="document.getElementById('addCatModal').style.display='none'; document.body.classList.remove('no-scroll');">
<div class="pf-modal-content" onclick="event.stopPropagation()">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 class="pf-modal-title" style="margin: 0; font-size: 1.25rem;"><?= tr('bud_prev_new_line_title') ?></h3>
<button type="button" onclick="document.getElementById('addCatModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-modal-close" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color: var(--text-muted, #64748b);">×</button>
</div>
<form action="/modules/budget/includes/api/save-budget.php" method="POST">
<input type="hidden" name="action" value="add_category">
<div class="form-group">
<div class="form-group" style="margin-bottom: 1rem;">
<label class="pf-label"><?= tr('bud_prev_label_name') ?></label>
<input type="text" name="name" class="pf-input" required>
</div>
<div class="form-group">
<label class="pf-label"><?= tr('bud_prev_monthly_target') ?? 'Objectif Mensuel (€)' ?></label>
<input type="number" step="1" name="target" class="pf-input" placeholder="Ex: 150">
<div class="form-group" style="margin-bottom: 1rem;">
<label class="pf-label"><?= tr('bud_prev_monthly_target') ?></label>
<input type="number" step="1" name="target" class="pf-input" placeholder="<?= tr('bud_prev_target_ph') ?>">
</div>
<div class="form-group">
<label class="pf-label"><?= tr('bud_prev_transfer_dest') ?? 'Destination Virement (Optionnel)' ?></label>
<div class="form-group" style="margin-bottom: 1rem;">
<label class="pf-label"><?= tr('bud_prev_transfer_dest') ?></label>
<select name="transfer_dest" class="pf-input">
<option value="" selected>-- <?= tr('bud_prev_none') ?? 'Aucune' ?> --</option>
<option value="vers L.Pol">vers L.Pol</option>
<option value="vers L.Pep">vers L.Pep</option>
<option value="vers L.Perso">vers L.Perso</option>
<option value="vers commune">vers commune</option>
<option value="">-- <?= tr('bud_prev_none') ?> --</option>
<?php foreach ($bankAccounts as $accName): ?>
<option value="vers <?= htmlspecialchars($accName) ?>"><?= tr('bud_prev_transfer_to') ?> <?= htmlspecialchars($accName) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<div class="form-group" style="margin-bottom: 1rem;">
<label class="pf-label">🌴 <?= tr('bud_prev_link_holiday') ?></label>
<select name="holiday_id" class="pf-input">
<option value="">-- <?= tr('bud_prev_no_link') ?> --</option>
@@ -541,7 +567,8 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
<?php endforeach; ?>
</select>
</div>
<div class="modal-footer">
<div class="modal-footer" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 1.5rem;">
<button type="button" onclick="document.getElementById('addCatModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn"><?= tr('bud_add_title') ?></button>
</div>
@@ -549,34 +576,39 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
</div>
</div>
<div id="editCatModal" class="pf-modal">
<div class="pf-modal-content">
<div class="prev-header-left">
<h3 class="pf-modal-title"><?= tr('bud_prev_edit_line_title') ?></h3>
<button onclick="document.getElementById('editCatModal').style.display='none'; document.body.classList.remove('no-scroll');">&times;</button>
<div id="editCatModal" class="pf-modal" onclick="document.getElementById('editCatModal').style.display='none'; document.body.classList.remove('no-scroll');">
<div class="pf-modal-content" onclick="event.stopPropagation()">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 class="pf-modal-title" style="margin: 0; font-size: 1.25rem;"><?= tr('bud_prev_edit_line_title') ?></h3>
<button type="button" onclick="document.getElementById('editCatModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-modal-close" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color: var(--text-muted, #64748b);">×</button>
</div>
<form action="/modules/budget/includes/api/save-budget.php" method="POST">
<input type="hidden" name="action" value="update_category">
<input type="hidden" name="cat_id" id="edit_cat_id">
<div class="form-group">
<div class="form-group" style="margin-bottom: 1rem;">
<label class="pf-label"><?= tr('bud_label_name') ?></label>
<input type="text" name="name" id="edit_cat_name" class="pf-input" required>
</div>
<div class="form-group">
<label class="pf-label"><?= tr('bud_prev_monthly_target') ?? 'Objectif Mensuel (€)' ?></label>
<input type="number" step="1" name="target" id="edit_cat_target" class="pf-input" placeholder="Ex: 150">
<div class="form-group" style="margin-bottom: 1rem;">
<label class="pf-label"><?= tr('bud_prev_monthly_target') ?></label>
<input type="number" step="1" name="target" id="edit_cat_target" class="pf-input" placeholder="<?= tr('bud_prev_target_ph') ?>">
</div>
<div class="form-group">
<label class="pf-label"><?= tr('bud_prev_transfer_dest') ?? 'Destination Virement (Optionnel)' ?></label>
<div class="form-group" style="margin-bottom: 1rem;">
<label class="pf-label"><?= tr('bud_prev_transfer_dest') ?></label>
<select name="transfer_dest" id="edit_cat_transfer_dest" class="pf-input">
<option value="">-- <?= tr('bud_prev_none') ?? 'Aucune' ?> --</option>
<option value="vers L.Pol">vers L.Pol</option>
<option value="vers L.Pep">vers L.Pep</option>
<option value="vers L.Perso">vers L.Perso</option>
<option value="vers commune">vers commune</option>
<option value="">-- <?= tr('bud_prev_none') ?> --</option>
<?php foreach ($bankAccounts as $accName): ?>
<option value="vers <?= htmlspecialchars($accName) ?>"><?= tr('bud_prev_transfer_to') ?> <?= htmlspecialchars($accName) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<div class="form-group" style="margin-bottom: 1rem;">
<label class="pf-label">🌴 <?= tr('bud_prev_link_holiday') ?></label>
<select name="holiday_id" id="edit_cat_holiday" class="pf-input">
<option value="">-- <?= tr('bud_prev_no_link') ?> --</option>
@@ -585,7 +617,8 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
<?php endforeach; ?>
</select>
</div>
<div class="modal-footer">
<div class="modal-footer" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 1.5rem;">
<button type="button" onclick="document.getElementById('editCatModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</div>
@@ -604,8 +637,9 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
<div class="pf-form-group">
<label class="pf-label"><?= tr('bud_adv_who_paid') ?></label>
<select name="payer" class="pf-input" required>
<option value="<?= htmlspecialchars($p1_name) ?>"><?= htmlspecialchars($p1_name) ?></option>
<option value="<?= htmlspecialchars($p2_name) ?>"><?= htmlspecialchars($p2_name) ?></option>
<?php foreach ($parentMapping as $map): ?>
<option value="<?= htmlspecialchars($map['name']) ?>"><?= htmlspecialchars($map['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="pf-form-group">
@@ -644,8 +678,9 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
<div class="pf-form-group">
<label class="pf-label"><?= tr('bud_adv_who_paid') ?></label>
<select name="payer" id="edit_adv_payer" class="pf-input" required>
<option value="<?= htmlspecialchars($p1_name) ?>"><?= htmlspecialchars($p1_name) ?></option>
<option value="<?= htmlspecialchars($p2_name) ?>"><?= htmlspecialchars($p2_name) ?></option>
<?php foreach ($parentMapping as $map): ?>
<option value="<?= htmlspecialchars($map['name']) ?>"><?= htmlspecialchars($map['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="pf-form-group">
@@ -684,20 +719,6 @@ elseif (($advTotal[$p2_name] ?? 0) > ($advTotal[$p1_name] ?? 0)) $owedTo = $p2_n
<script>
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
// Dictionnaire I18N injecté proprement depuis PHP
window.I18N = {
...(window.I18N || {}),
'bud_prev_label_name': <?= json_encode(tr('bud_prev_label_name')) ?>,
'bud_prev_err_no_history': <?= json_encode(tr('bud_prev_err_no_history')) ?>,
'bud_prev_confirm_copy': <?= json_encode(tr('bud_prev_confirm_copy')) ?>,
'bud_prev_confirm_transfers': <?= json_encode(tr('bud_prev_confirm_transfers')) ?>,
'bud_prev_confirm_del_line': <?= json_encode(tr('bud_prev_confirm_del_line')) ?>,
'bud_adv_confirm_resolve': <?= json_encode(tr('bud_adv_confirm_resolve') ?? 'Confirmer le remboursement ?') ?>,
'bud_adv_confirm_delete': <?= json_encode(tr('bud_adv_confirm_delete') ?? 'Supprimer définitivement ?') ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>
};
window.CONFIG = window.CONFIG || {};
window.CONFIG.parentMapping = <?= json_encode($parentMapping) ?>;
window.CONFIG.CURRENCY = '<?= defined('CURRENCY') ? CURRENCY : "€" ?>';
@@ -746,7 +767,7 @@ function duplicateMonth() {
const parentMap = window.CONFIG.parentMapping;
if (!sourceDateStr) {
alert(window.I18N['bud_prev_err_no_history']);
alert(tr('bud_prev_err_no_history'));
return;
}
@@ -757,7 +778,7 @@ function duplicateMonth() {
const sourceName = formatMonth(sourceDateStr);
const targetName = formatMonth(targetDateStr);
const message = window.I18N['bud_prev_confirm_copy'].replace('%s', sourceName).replace('%t', targetName);
const message = tr('bud_prev_confirm_copy').replace('%s', sourceName).replace('%t', targetName);
if(!confirm(message)) return;
@@ -883,10 +904,9 @@ function updateSummaryTable() {
function saveData(action, data) {
const formData = new FormData();
formData.append('action', action);
formData.append('ajax', '1'); // <-- Sécurisation pour pachaFetch / backend
formData.append('ajax', '1');
for (const key in data) formData.append(key, data[key]);
// On utilise fetch ici en mode aveugle pour ne pas bloquer l'UI lors de la frappe rapide
fetch('/modules/budget/includes/api/save-budget.php', { method: 'POST', body: formData });
}
@@ -894,7 +914,7 @@ async function validateTransfers(personCss, month) {
const parentMap = window.CONFIG.parentMapping.find(m => m.css === personCss);
if (!parentMap) return;
const msg = window.I18N['bud_prev_confirm_transfers'].replace('%p', parentMap.name).replace('%m', month);
const msg = tr('bud_prev_confirm_transfers').replace('%p', parentMap.name).replace('%m', month);
if (!confirm(msg)) return;
const formData = new FormData();
@@ -908,10 +928,10 @@ async function validateTransfers(personCss, month) {
if(result.success) {
window.location.reload();
} else {
alert(window.I18N['bud_err_tech'] + " : " + result.error);
alert(tr('bud_err_tech') + " : " + result.error);
}
} catch(e) {
alert(window.I18N['bud_err_tech']);
alert(tr('bud_err_tech'));
}
}
@@ -932,15 +952,15 @@ async function saveGenericNote(noteType, refId, content) {
setTimeout(() => indicator.style.opacity = '0', 2000);
}
} else {
alert(window.I18N['bud_err_tech'] + " : " + data.error);
alert(tr('bud_err_tech') + " : " + data.error);
}
} catch(e) {
alert(window.I18N['bud_err_tech']);
alert(tr('bud_err_tech'));
}
}
async function deleteCategory(id) {
if (!confirm(window.I18N['bud_prev_confirm_del_line'])) return;
if (!confirm(tr('bud_prev_confirm_del_line'))) return;
const formData = new FormData();
formData.append('action', 'delete_category');
formData.append('id', id);
@@ -980,29 +1000,28 @@ async function handleAdvanceSubmit(event, form) {
const endpoint = form.getAttribute('action');
const formData = new FormData(form);
formData.append('ajax', '1'); // <-- Correction de l'erreur d'empty string
formData.append('ajax', '1');
try {
// Utilisation robuste de pachaFetch
const result = await pachaFetch(endpoint, { method: 'POST', body: formData });
if (result.success) {
window.location.reload();
} else {
alert((window.I18N['bud_err_tech'] || "Erreur") + " : " + (result.error || "Opération échouée"));
alert((tr('bud_err_tech') || "Erreur") + " : " + (result.error || "Opération échouée"));
btnSubmit.disabled = false;
btnSubmit.innerHTML = oldText;
}
} catch (err) {
console.error("AJAX Error:", err);
alert(window.I18N['bud_err_tech'] || "Une erreur technique est survenue.");
alert(tr('bud_err_tech') || "Une erreur technique est survenue.");
btnSubmit.disabled = false;
btnSubmit.innerHTML = oldText;
}
}
async function executeResolveAdvance(id) {
if (!confirm(window.I18N['bud_adv_confirm_resolve'])) return;
if (!confirm(tr('bud_adv_confirm_resolve'))) return;
const fd = new FormData();
fd.append('action', 'resolve_advance');
fd.append('id', id);
@@ -1017,7 +1036,7 @@ async function executeResolveAdvance(id) {
}
async function executeDeleteAdvance(id) {
if (!confirm(window.I18N['bud_adv_confirm_delete'])) return;
if (!confirm(tr('bud_adv_confirm_delete'))) return;
const fd = new FormData();
fd.append('action', 'delete_advance');
fd.append('id', id);
@@ -1087,7 +1106,6 @@ document.addEventListener('click', function(e) {
document.addEventListener('DOMContentLoaded', recalcAllAllocations);
// Soumission standard des modales de catégories avec pachaFetch
document.querySelectorAll('#addCatModal form, #editCatModal form').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
@@ -1097,7 +1115,7 @@ document.querySelectorAll('#addCatModal form, #editCatModal form').forEach(form
submitBtn.disabled = true;
submitBtn.innerText = '⏳ ...';
const formData = new FormData(form);
formData.append('ajax', '1'); // Toujours sécuriser
formData.append('ajax', '1');
const actionUrl = form.getAttribute('action');
const result = await pachaFetch(actionUrl, { method: 'POST', body: formData });
@@ -1107,10 +1125,10 @@ document.querySelectorAll('#addCatModal form, #editCatModal form').forEach(form
document.body.classList.remove('no-scroll');
window.location.reload();
} else {
alert((window.I18N['bud_err_tech'] || 'Erreur') + " : " + (result.error || "Inconnue"));
alert((tr('bud_err_tech') || 'Erreur') + " : " + (result.error || "Inconnue"));
}
} catch (error) {
alert(window.I18N['bud_err_tech'] || "Une erreur technique est survenue.");
alert(tr('bud_err_tech') || "Une erreur technique est survenue.");
} finally {
submitBtn.disabled = false;
submitBtn.innerText = originalText;
+484
View File
@@ -0,0 +1,484 @@
<?php
// Sécurité : Ce fichier ne doit pas être appelé directement
if (!defined('CURRENCY')) {
define('CURRENCY', '€');
}
?>
<div class="budget-settings-backdrop" id="modal-budget-settings">
<div class="budget-settings-modal">
<div class="bs-header">
<h3>⚙️ <?= tr('budget_settings_title') ?></h3>
<button class="bs-close" onclick="closeBudgetSettings()">×</button>
</div>
<div class="bs-layout">
<div class="bs-sidebar">
<button class="bs-tab-btn active" onclick="switchBsTab('accounts', this)">
<?= tr('bs_tab_accounts') ?>
<?php if (!$hasAccounts): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
</button>
<button class="bs-tab-btn" onclick="switchBsTab('categories', this)">
<?= tr('bs_tab_categories') ?>
<?php if (!$hasCategories): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
</button>
<button class="bs-tab-btn" onclick="switchBsTab('rules', this)"><?= tr('bs_tab_rules') ?></button>
<button class="bs-tab-btn" onclick="switchBsTab('salaries', this)">
<?= tr('bs_tab_salaries') ?>
<?php if (!$hasSalaries): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
</button>
<button class="bs-tab-btn" onclick="switchBsTab('csv', this)"><?= tr('bs_tab_csv') ?></button>
</div>
<div class="bs-content">
<?php if (!isset($isBudgetSetupOk) || !$isBudgetSetupOk || !$hasBudgetItems): ?>
<div class="bs-onboarding-card">
<h4 class="bs-onboarding-title">
<span>🚀</span> <?= tr('bs_onb_title') ?>
</h4>
<p class="bs-onboarding-desc"><?= tr('bs_onb_desc') ?></p>
<ul class="bs-onboarding-list">
<li>
<strong class="<?= $hasCategories ? 'text-success' : 'text-danger' ?>"><?= $hasCategories ? '✅' : '❌' ?></strong>
<a class="bs-onboarding-link" onclick="switchBsTab('categories', document.querySelectorAll('.bs-tab-btn')[1]); return false;"><?= tr('bs_onb_cat_link') ?></a> : <?= tr('bs_onb_cat_desc') ?>
</li>
<li>
<strong class="<?= $hasAccounts ? 'text-success' : 'text-danger' ?>"><?= $hasAccounts ? '✅' : '❌' ?></strong>
<a class="bs-onboarding-link" onclick="switchBsTab('accounts', document.querySelectorAll('.bs-tab-btn')[0]); return false;"><?= tr('bs_onb_acc_link') ?></a> : <?= tr('bs_onb_acc_desc') ?>
</li>
<li>
<strong class="<?= $hasSalaries ? 'text-success' : 'text-danger' ?>"><?= $hasSalaries ? '✅' : '❌' ?></strong>
<a class="bs-onboarding-link" onclick="switchBsTab('salaries', document.querySelectorAll('.bs-tab-btn')[3]); return false;"><?= tr('bs_onb_sal_link') ?></a> : <?= tr('bs_onb_sal_desc') ?>
</li>
<li style="margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(180, 83, 9, 0.3);">
<strong class="<?= $hasBudgetItems ? 'text-success' : 'text-warning' ?>" style="color: <?= $hasBudgetItems ? '' : '#f59e0b' ?>"><?= $hasBudgetItems ? '✅' : '⏳' ?></strong>
<a href="?tab=recap" onclick="closeBudgetSettings();" class="bs-onboarding-link"><?= tr('bs_onb_items_link') ?></a> : <?= tr('bs_onb_items_desc') ?>
</li>
</ul>
</div>
<?php endif; ?>
<div id="pane-accounts" class="bs-pane active">
<div class="bs-pane-header">
<h4 class="bs-section-title"><?= tr('bs_tab_accounts') ?></h4>
<div class="bs-currency-selector">
<span><?= tr('bs_currency_label') ?> :</span>
<select id="bs-currency-select" class="pf-input bs-currency-select" onchange="updateBudgetCurrency(this)">
<option value="€">EUR (€)</option>
<option value="$">USD ($)</option>
<option value="£">GBP (£)</option>
<option value="CHF">CHF</option>
</select>
</div>
</div>
<div id="bs-list-accounts" class="bs-list-container">⏳ <?= tr('loading') ?></div>
<hr class="bs-divider">
<h5 class="bs-section-subtitle"><?= tr('bs_add_account_title') ?></h5>
<form id="form-add-account" class="bs-form-inline">
<input type="text" name="name" class="pf-input bs-input-flex" placeholder="<?= tr('bs_account_name_ph') ?>" required>
<select name="type" class="pf-input bs-input-fixed">
<option value="checking"><?= tr('bs_type_checking') ?></option>
<option value="savings"><?= tr('bs_type_savings') ?></option>
</select>
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-categories" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_categories') ?></h4>
<div id="bs-list-categories" class="bs-list-container">⏳ <?= tr('loading') ?></div>
<hr class="bs-divider">
<h5 class="bs-section-subtitle"><?= tr('bs_add_category_title') ?></h5>
<form id="form-add-category" class="bs-form-inline align-center">
<input type="text" name="code" class="pf-input bs-input-fixed" placeholder="<?= tr('bs_cat_code_ph') ?>" required>
<input type="text" name="label" class="pf-input bs-input-flex" placeholder="<?= tr('bs_cat_label_ph') ?>" required>
<select name="type" class="pf-input bs-input-fixed">
<option value="Expense"><?= tr('bs_type_expense') ?></option>
<option value="Income"><?= tr('bs_type_income') ?></option>
<option value="Savings"><?= tr('bs_type_savings_cat') ?></option>
</select>
<select name="icon" class="pf-input bs-input-icon">
<option value="📌">📌</option><option value="🛒">🛒</option><option value="🥖">🥖</option>
<option value="🍽️">🍽️</option><option value="⛽">⛽</option><option value="🚗">🚗</option>
<option value="🚆">🚆</option><option value="🏠">🏠</option><option value="⚡">⚡</option>
<option value="💧">💧</option><option value="⚕️">⚕️</option><option value="🎒">🎒</option>
<option value="👕">👕</option><option value="📱">📱</option><option value="🎮">🎮</option>
<option value="✈️">✈️</option><option value="🎁">🎁</option><option value="🐶">🐶</option>
<option value="💵">💵</option><option value="🐷">🐷</option><option value="📈">📈</option>
</select>
<input type="color" name="color" value="#3b82f6" class="bs-input-color">
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-rules" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_rules') ?></h4>
<input type="text" id="input-search-rules" class="bs-rule-search" placeholder="<?= tr('bs_rule_search_ph') ?>" onkeyup="filterRules()">
<div id="bs-list-rules" class="bs-list-container">⏳ <?= tr('loading') ?></div>
<hr class="bs-divider">
<h5 class="bs-section-subtitle"><?= tr('bs_add_rule_title') ?></h5>
<form id="form-add-rule" class="bs-form-inline align-center">
<input type="text" name="keyword" class="pf-input bs-input-flex" placeholder="<?= tr('bs_rule_keyword_ph') ?>" required>
<select name="category" id="select-rule-category" class="pf-input bs-input-fixed" required></select>
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-salaries" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_salaries') ?> (<?= date('Y') ?>)</h4>
<div id="bs-list-salaries" class="bs-list-container">⏳ <?= tr('loading') ?></div>
</div>
<div id="pane-csv" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_csv_title') ?></h4>
<p class="pf-muted-tiny bs-desc"><?= tr('bs_csv_desc') ?></p>
<div id="csv-drop-zone" class="bs-csv-dropzone">
<div class="bs-csv-icon">📥</div>
<h5 class="bs-csv-title"><?= tr('bs_csv_drop_title') ?></h5>
<p class="pf-muted-tiny bs-csv-subtitle"><?= tr('bs_csv_drop_desc') ?></p>
<input type="file" id="csv_file_input" accept=".csv" style="display: none;">
</div>
<div id="csv-preview-container" class="bs-csv-preview"></div>
<h5 class="bs-section-subtitle bordered"><?= tr('bs_csv_settings_title') ?></h5>
<form id="form-csv-mapping" onsubmit="saveCsvMapping(event)">
<div class="bs-grid-2">
<div>
<label class="pf-label"><?= tr('bs_csv_delimiter') ?></label>
<select name="csv_delimiter" id="csv_delimiter" class="pf-input">
<option value=";"><?= tr('bs_csv_delim_semi') ?></option>
<option value=","><?= tr('bs_csv_delim_comma') ?></option>
<option value="\t"><?= tr('bs_csv_delim_tab') ?></option>
</select>
</div>
<div>
<label class="pf-label"><?= tr('bs_csv_date_format') ?></label>
<select name="csv_date_format" id="csv_date_format" class="pf-input">
<option value="d/m/Y">JJ/MM/AAAA</option>
<option value="Y-m-d">AAAA-MM-JJ</option>
</select>
</div>
</div>
<h5 class="bs-section-subtitle">
<?= tr('bs_csv_col_index') ?>
<small class="bs-text-muted-normal"><?= tr('bs_csv_col_index_help') ?></small>
</h5>
<div class="bs-grid-2">
<div>
<label class="pf-label"><?= tr('bs_csv_col_date') ?></label>
<input type="number" min="0" name="csv_col_date" id="csv_col_date" class="pf-input" value="0" required>
</div>
<div>
<label class="pf-label"><?= tr('bs_csv_col_label') ?></label>
<input type="number" min="0" name="csv_col_label" id="csv_col_label" class="pf-input" value="1" required>
</div>
</div>
<div class="bs-card-gray">
<label class="pf-label"><?= tr('bs_csv_amount_mgmt') ?></label>
<select name="csv_amount_type" id="csv_amount_type" class="pf-input bs-input-mb" onchange="toggleCsvAmountCols(this.value)">
<option value="single"><?= tr('bs_csv_amount_single') ?></option>
<option value="split"><?= tr('bs_csv_amount_split') ?></option>
</select>
<div class="bs-grid-2">
<div>
<label class="pf-label" id="lbl_col_debit"><?= tr('bs_csv_col_amount') ?></label>
<input type="number" min="0" name="csv_col_debit" id="csv_col_debit" class="pf-input" value="8" required>
</div>
<div id="wrapper_col_credit" style="display:none;">
<label class="pf-label"><?= tr('bs_csv_col_credit') ?></label>
<input type="number" min="0" name="csv_col_credit" id="csv_col_credit" class="pf-input" value="9">
</div>
</div>
</div>
<div class="bs-input-mb">
<label class="pf-label"><?= tr('bs_csv_col_ref') ?></label>
<input type="number" min="0" name="csv_col_ref" id="csv_col_ref" class="pf-input" value="3">
</div>
<div class="bs-text-right">
<button type="submit" class="btn btn-secondary">💾 <?= tr('btn_save_format') ?></button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tabsContainer = document.querySelector('.budget-tabs-container');
const activeTab = document.querySelector('.tab-item.active');
if (tabsContainer && activeTab) {
if (tabsContainer.scrollWidth > tabsContainer.clientWidth) {
const scrollPos = activeTab.offsetLeft - (tabsContainer.offsetWidth / 2) + (activeTab.offsetWidth / 2);
tabsContainer.scrollTo({ left: scrollPos, behavior: 'smooth' });
}
}
});
function openBudgetSettings() {
document.getElementById('modal-budget-settings').classList.add('show');
document.body.classList.add('no-scroll');
loadBudgetSettingsData();
}
function closeBudgetSettings() {
document.getElementById('modal-budget-settings').classList.remove('show');
document.body.classList.remove('no-scroll');
}
document.getElementById('modal-budget-settings')?.addEventListener('click', (e) => {
if (e.target.id === 'modal-budget-settings') closeBudgetSettings();
});
function switchBsTab(tabId, btnEl) {
document.querySelectorAll('.bs-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.bs-pane').forEach(p => p.classList.remove('active'));
btnEl.classList.add('active');
document.getElementById('pane-' + tabId).classList.add('active');
}
async function loadBudgetSettingsData() {
try {
const response = await pachaFetch('/modules/budget/includes/api/settings.php?action=get_all', { method: 'GET' });
if (!response.success) throw new Error(response.error || tr('error_occured'));
renderAccounts(response.data.accounts);
renderCategories(response.data.categories);
populateRuleCategories(response.data.categories);
renderRules(response.data.rules);
renderSalaries(response.data.salaries, response.data.year);
const currencySelect = document.getElementById('bs-currency-select');
if (currencySelect && response.data.currency) currencySelect.value = response.data.currency;
} catch (err) {
console.error("Erreur chargement paramètres :", err);
const errorMsg = `<div class="pf-alert pf-alert--error bs-input-mb">❌ ${tr('error_loading_settings')} : ${err.message}</div>`;
['accounts', 'categories', 'rules', 'salaries'].forEach(id => {
const el = document.getElementById('bs-list-' + id);
if (el) el.innerHTML = errorMsg;
});
}
}
// 1. ONGLET : COMPTES
function renderAccounts(accounts) {
const container = document.getElementById('bs-list-accounts');
if (!container) return;
if (!accounts || accounts.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_accounts')}</em>`;
return;
}
let html = '';
accounts.forEach(acc => {
const typeBadge = acc.account_type === 'checking' ? tr('bs_type_checking') : tr('bs_type_savings');
const defaultBadge = acc.is_default == 1 ? `<span class="bs-badge-default">${tr('bs_badge_default')}</span>` : '';
const ownerText = acc.owner_name ? `${tr('bs_owner')} : ${acc.owner_name}` : tr('bs_shared_account');
html += `
<div class="bs-list-item">
<div>
<strong>${acc.name}</strong> ${defaultBadge}<br>
<small class="text-muted">${typeBadge} · ${ownerText}</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action delete" onclick="deleteAccount(${acc.id})" title="${tr('btn_delete')}">🗑️</button>
</div>
</div>`;
});
container.innerHTML = html;
}
// 2. ONGLET : CATÉGORIES
function renderCategories(categories) {
const container = document.getElementById('bs-list-categories');
if (!container) return;
if (!categories || categories.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_categories')}</em>`;
return;
}
let html = '';
categories.forEach(cat => {
let typeLabel = cat.type === 'Income' ? tr('bs_type_income') : cat.type === 'Expense' ? tr('bs_type_expense') : tr('bs_type_savings_cat');
html += `
<div class="bs-list-item" style="border-left: 4px solid ${cat.color || '#ccc'};">
<div>
<span style="font-size:1.2rem; margin-right:8px;">${cat.icon || '📌'}</span>
<strong>${cat.label}</strong> <small class="text-muted">(${cat.code} — ${typeLabel})</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action delete" onclick="deleteCategory(${cat.id})" title="${tr('btn_delete')}">🗑️</button>
</div>
</div>`;
});
container.innerHTML = html;
}
// 3. ONGLET : RÈGLES D'IMPORT
function renderRules(rules) {
const container = document.getElementById('bs-list-rules');
if (!container) return;
if (!rules || rules.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_rules')}</em>`;
return;
}
const groupedRules = {};
rules.forEach(r => {
const catName = r.cat_label || r.category;
if (!groupedRules[catName]) groupedRules[catName] = [];
groupedRules[catName].push(r);
});
let html = '';
for (const [catName, catRules] of Object.entries(groupedRules)) {
html += `
<details class="pf-accordion js-rule-group" data-catname="${catName.toLowerCase()}">
<summary class="pf-accordion-summary bs-accordion-summary">
<span>📁 ${catName}</span>
<span class="bs-accordion-count">${catRules.length}</span>
</summary>
<div class="pf-accordion-content" style="padding: 0.5rem;">
<div class="bs-rule-tags">`;
catRules.forEach(r => {
html += `<div class="bs-rule-tag js-rule-tag" data-keyword="${r.keyword.toLowerCase()}">
${r.keyword}
<button type="button" class="bs-rule-tag-del" onclick="deleteRule(${r.id})" title="${tr('btn_delete')}">×</button>
</div>`;
});
html += ` </div>
</div>
</details>`;
}
container.innerHTML = html;
}
function filterRules() {
const searchVal = document.getElementById('input-search-rules').value.toLowerCase();
document.querySelectorAll('.js-rule-group').forEach(group => {
let hasVisibleTag = false;
const catName = group.getAttribute('data-catname');
group.querySelectorAll('.js-rule-tag').forEach(tag => {
const keyword = tag.getAttribute('data-keyword');
if (keyword.includes(searchVal)) { tag.style.display = 'inline-flex'; hasVisibleTag = true; }
else { tag.style.display = 'none'; }
});
if (catName.includes(searchVal) && searchVal.length > 0) {
group.querySelectorAll('.js-rule-tag').forEach(tag => tag.style.display = 'inline-flex');
hasVisibleTag = true;
}
if (hasVisibleTag) {
group.style.display = 'block';
if (searchVal.length > 0) group.setAttribute('open', '');
} else {
group.style.display = 'none'; group.removeAttribute('open');
}
});
}
// 5. ONGLET : SALAIRES
function renderSalaries(salaries, year) {
const container = document.getElementById('bs-list-salaries');
if (!container) return;
if (!salaries || salaries.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_salaries')} ${year}.</em>`;
return;
}
const currency = '<?= CURRENCY ?>';
let html = `<p class="bs-desc">${tr('bs_salaries_year_desc')} <strong>${year}</strong>.</p>`;
salaries.forEach(s => {
html += `
<div class="bs-list-item" id="salary-item-${s.id}">
<div>
<strong>${s.person}</strong><br>
<small class="text-muted">
${tr('bs_salary_net')} : ${s.salary} ${currency}<br>
${tr('bs_salary_fees')} : ${s.mensualite} ${currency}
</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action edit" data-id="${s.id}" data-person="${s.person}" data-salary="${s.salary}" data-mensualite="${s.mensualite}" onclick="inlineEditSalary(this)" title="${tr('btn_edit')}">✏️</button>
</div>
</div>`;
});
container.innerHTML = html;
}
// --- CRUD ACTIONS ---
async function deleteAccount(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete'))) return; try { const fd = new FormData(); fd.append('action', 'delete_account'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
const formAddAccount = document.getElementById('form-add-account');
if (formAddAccount) formAddAccount.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddAccount.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddAccount); fd.append('action', 'add_account'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddAccount.reset(); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
async function deleteCategory(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete_cat'))) return; try { const fd = new FormData(); fd.append('action', 'delete_category'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
const formAddCategory = document.getElementById('form-add-category');
if (formAddCategory) formAddCategory.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddCategory.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddCategory); fd.append('action', 'add_category'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddCategory.reset(); formAddCategory.querySelector('input[type="color"]').value = "#3b82f6"; await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
function populateRuleCategories(categories) { const select = document.getElementById('select-rule-category'); if (!select) return; select.innerHTML = ''; if (!categories) return; categories.forEach(cat => { const opt = document.createElement('option'); opt.value = cat.code; opt.textContent = `${cat.icon || ''} ${cat.label}`; select.appendChild(opt); }); }
async function deleteRule(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete_rule'))) return; try { const fd = new FormData(); fd.append('action', 'delete_rule'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
const formAddRule = document.getElementById('form-add-rule');
if (formAddRule) formAddRule.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddRule.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddRule); fd.append('action', 'add_rule'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddRule.reset(); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
function inlineEditSalary(btnEl) {
const id = btnEl.dataset.id; const person = btnEl.dataset.person; const salary = btnEl.dataset.salary; const mensualite = btnEl.dataset.mensualite; const currency = '<?= CURRENCY ?>';
document.getElementById(`salary-item-${id}`).innerHTML = `
<form onsubmit="submitInlineSalary(event, ${id})" class="bs-form-inline align-center">
<div class="bs-input-flex"><strong>${person}</strong></div>
<div style="display:flex; align-items:center; gap:4px;"><small class="text-muted">${tr('bs_salary_net')}:</small><input type="number" step="0.01" name="salary" class="pf-input bs-input-fixed" value="${salary}" required> ${currency}</div>
<div style="display:flex; align-items:center; gap:4px;"><small class="text-muted">${tr('bs_salary_fees')}:</small><input type="number" step="0.01" name="mensualite" class="pf-input bs-input-fixed" value="${mensualite}" required> ${currency}</div>
<button type="submit" class="btn btn-secondary">💾</button>
<button type="button" class="btn btn-ghost" onclick="loadBudgetSettingsData()">❌</button>
</form>`;
}
async function submitInlineSalary(event, id) { event.preventDefault(); const form = event.target; const btn = form.querySelector('button'); btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(form); fd.append('action', 'save_salary'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); btn.innerText = '💾'; btn.disabled = false; } }
async function updateBudgetCurrency(selectEl) { try { const fd = new FormData(); fd.append('action', 'save_currency'); fd.append('currency', selectEl.value); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); if (typeof showToast === 'function') { showToast(`${tr('bs_currency_updated')} ✅`); } await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
// CSV
function toggleCsvAmountCols(type) { if (type === 'split') { document.getElementById('wrapper_col_credit').style.display = 'block'; document.getElementById('lbl_col_debit').innerText = tr('bs_csv_col_debit'); } else { document.getElementById('wrapper_col_credit').style.display = 'none'; document.getElementById('lbl_col_debit').innerText = tr('bs_csv_col_amount'); } }
function renderCsvMapping(mapping) { if (!mapping) return; document.getElementById('csv_delimiter').value = mapping.delimiter || ';'; document.getElementById('csv_date_format').value = mapping.date_format || 'd/m/Y'; document.getElementById('csv_col_date').value = mapping.col_date ?? 0; document.getElementById('csv_col_label').value = mapping.col_label ?? 1; document.getElementById('csv_col_ref').value = mapping.col_ref ?? 3; const amountType = mapping.amount_type || 'split'; document.getElementById('csv_amount_type').value = amountType; document.getElementById('csv_col_debit').value = mapping.col_debit ?? 8; document.getElementById('csv_col_credit').value = mapping.col_credit ?? 9; toggleCsvAmountCols(amountType); }
async function saveCsvMapping(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳...'; btn.disabled = true; try { const fd = new FormData(form); fd.append('action', 'save_csv_mapping'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); if (typeof showToast === 'function') showToast(tr('bs_csv_saved')); else alert(tr('bs_csv_saved')); } catch (err) { alert(tr('error_occured') + " : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } }
const dropZone = document.getElementById('csv-drop-zone'); const fileInput = document.getElementById('csv_file_input');
if (dropZone && fileInput) {
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.backgroundColor = 'rgba(0, 123, 255, 0.05)'; dropZone.style.borderColor = 'var(--primary-color, #007bff)'; });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.style.backgroundColor = ''; dropZone.style.borderColor = 'var(--border-light, #ccc)'; });
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.style.backgroundColor = ''; dropZone.style.borderColor = 'var(--border-light, #ccc)'; if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; handleCsvUpload(e.dataTransfer.files[0]); } });
fileInput.addEventListener('change', function() { if (this.files.length) { handleCsvUpload(this.files[0]); } });
}
function handleCsvUpload(file) {
if (!file.name.endsWith('.csv')) { alert(tr('bs_csv_err_type')); return; }
if (typeof showToast === 'function') { showToast(`${tr('bs_csv_file_ok')} : ${file.name}`); }
const reader = new FileReader();
reader.onload = function(e) {
const text = e.target.result; const delimiter = document.getElementById('csv_delimiter').value || ';';
const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
if (lines.length > 0) {
const headers = lines[0].split(delimiter).map(h => h.replace(/^"|"$/g, '').trim());
const sampleRows = [];
for (let i = 1; i < Math.min(lines.length, 3); i++) { sampleRows.push(lines[i].split(delimiter).map(d => d.replace(/^"|"$/g, '').trim())); }
renderCsvTablePreview(headers, sampleRows);
}
};
reader.readAsText(file, 'ISO-8859-1');
}
function renderCsvTablePreview(headers, sampleRows) {
const container = document.getElementById('csv-preview-container');
let html = `<h5 class="bs-section-subtitle">👁️ ${tr('bs_csv_preview_title')}</h5>`;
html += `<div class="bs-csv-table-wrapper"><table class="bs-csv-table"><thead class="bs-csv-thead"><tr>`;
headers.forEach((header, index) => {
const cleanHeader = header || `(${tr('bs_csv_empty_col')})`;
html += `<th class="bs-csv-th"><div class="bs-csv-th-index">N° ${index}</div><div>${cleanHeader}</div></th>`;
});
html += `</tr></thead><tbody>`;
if (sampleRows.length === 0) { html += `<tr><td colspan="${headers.length}" class="bs-csv-td-empty">${tr('bs_csv_no_data')}</td></tr>`; }
else { sampleRows.forEach(row => { html += `<tr class="bs-csv-tr">`; for (let i = 0; i < headers.length; i++) { const cellData = row[i] !== undefined ? row[i] : ''; const displayData = cellData.length > 40 ? cellData.substring(0, 40) + '...' : cellData; html += `<td class="bs-csv-td">${displayData}</td>`; } html += `</tr>`; }); }
html += `</tbody></table></div>`;
container.innerHTML = html; container.style.display = 'block';
}
</script>
+303 -177
View File
@@ -13,7 +13,7 @@ let localCareModes = [];
async function openCalendarSettings() {
try {
const res = await pachaFetch(
"/modules/family-calendar/includes/api/settings.php?action=get_all",
"/modules/family-calendar/includes/api/calendar-settings.php?action=get_all",
);
if (!res.success) throw new Error(res.error);
@@ -63,29 +63,34 @@ function closeCalendarSettings() {
document.body.classList.remove("no-scroll");
}
function switchCalendarTab(tabName) {
document
.querySelectorAll(".cal-settings-pane")
.forEach((p) => (p.style.display = "none"));
function switchCalendarTab(tabId) {
// 1. On réinitialise l'affichage
document
.querySelectorAll(".bs-tab-btn")
.forEach((b) => b.classList.remove("active"));
.forEach((btn) => btn.classList.remove("active"));
document
.querySelectorAll(".cal-settings-pane")
.forEach((pane) => (pane.style.display = "none"));
if (tabName === "foyer") {
document.getElementById("cal-pane-foyer").style.display = "block";
document.getElementById("tab-btn-foyer").classList.add("active");
} else if (tabName === "affichage") {
document.getElementById("cal-pane-affichage").style.display = "block";
document.getElementById("tab-btn-affichage").classList.add("active");
} else {
document.getElementById("cal-pane-membres").style.display = "block";
document.getElementById("tab-btn-membres").classList.add("active");
// 2. On active l'onglet cliqué
document.getElementById(`tab-btn-${tabId}`).classList.add("active");
document.getElementById(`cal-pane-${tabId}`).style.display = "block";
// 3. Actions spécifiques
if (tabId === "foyer") {
loadLeaveCatalog();
} else if (tabId === "membres") {
// On charge la vue du membre sélectionné par défaut dans la liste
if (document.getElementById("selectCalMember").value) {
loadMemberConfigView();
}
}
}
function renderCareModeTags() {
const container = document.getElementById("careModesContainer");
if (!container) return; // 🛑 LA BARRIÈRE DE SÉCURITÉ EST ICI
container.innerHTML = localCareModes
.map(
(mode, i) => `
@@ -97,8 +102,11 @@ function renderCareModeTags() {
.join("");
}
// Ajoutons la même sécurité pour l'ajout au cas où :
function addCareModeTag() {
const input = document.getElementById("inputNewCareMode");
if (!input) return; // 🛑 SÉCURITÉ
const val = input.value.trim();
if (val && !localCareModes.includes(val)) {
localCareModes.push(val);
@@ -124,7 +132,7 @@ async function submitCalFoyer(e) {
formData.append("care_modes", JSON.stringify(localCareModes));
const res = await pachaFetch(
"/modules/family-calendar/includes/api/settings.php",
"/modules/family-calendar/includes/api/calendar-settings.php",
{ method: "POST", body: formData },
);
if (!res.success) throw new Error(res.error);
@@ -142,139 +150,195 @@ async function submitCalFoyer(e) {
}
}
window.addLeaveRowToMatrix = function () {
const tbody = document.getElementById("tbodyLeaveMatrix");
const empty = document.getElementById("emptyMatrixRow");
if (empty) empty.remove();
if (!tbody) return;
window.submitChildCareModes = async function () {
if (!window.currentSelectedMemberId) return;
const checkboxes = document.querySelectorAll(".js-care-mode-cb:checked");
const selectedModes = Array.from(checkboxes).map((cb) => cb.value);
try {
const formData = new FormData();
formData.append("action", "save_child_care_modes");
formData.append("person_id", window.currentSelectedMemberId);
formData.append("care_modes", JSON.stringify(selectedModes));
const rowTypeOptions = (calGlobalData.leave_types || [])
.map((lt) => `<option value="${lt.code}">${lt.code} - ${lt.label}</option>`)
.join("");
const res = await pachaFetch(
"/modules/family-calendar/includes/api/calendar-settings.php",
{ method: "POST", body: formData },
);
if (!res.success) throw new Error(res.error);
if (window.showToast)
showToast(tr("fc_child_saved") || "Sauvegardé !", "success");
else alert(tr("fc_child_saved") || "Sauvegardé !");
const trRow = document.createElement("tr");
trRow.className = "js-leave-row";
trRow.style.borderBottom = "1px solid var(--border-light)";
trRow.innerHTML = `
<td style="padding: 6px 2px;">
<select class="pf-input js-leave-type" required style="width:100%; padding:6px 2px; font-size:0.8rem; box-sizing: border-box;">
<option value="">${tr("fc_leave_code_placeholder") || "Code..."}</option>
${rowTypeOptions}
</select>
</td>
<td style="padding: 6px 2px;">
<select class="pf-input js-leave-method" style="width:100%; padding:6px 2px; font-size:0.8rem; box-sizing: border-box;">
<option value="FIXED">${tr("fc_leave_method_fixed") || "Fixe"}</option>
<option value="ACCUMULATED">${tr("fc_leave_method_accumulated") || "Cumul"}</option>
</select>
</td>
<td style="padding: 6px 2px;"><input type="number" step="0.5" class="pf-input js-leave-allowance" placeholder="0" required style="width:100%; padding:6px 4px; font-size:0.85rem; text-align:center; box-sizing: border-box;"></td>
<td style="padding: 6px 2px;"><input type="text" class="pf-input js-leave-date" placeholder="${tr("fc_date_format_placeholder") || "JJ/MM"}" pattern="(0[1-9]|[12][0-9]|3[01])/(0[1-9]|1[012])" maxlength="5" required style="width:100%; padding:6px 4px; font-size:0.85rem; text-align:center; box-sizing: border-box;" title="${tr("fc_date_format_title") || "Format JJ/MM"}"></td>
<td style="padding: 6px 2px; text-align:right;"><button type="button" class="btn-icon-action delete" style="padding:4px; margin:0;" onclick="this.closest('tr').remove()" title="${tr("btn_delete") || "Supprimer"}">🗑️</button></td>
`;
tbody.appendChild(trRow);
const currentPerson = calGlobalData.people.find(
(k) => parseInt(k.id) === window.currentSelectedMemberId,
);
if (currentPerson) {
currentPerson.care_modes = JSON.stringify(selectedModes);
}
} catch (err) {
alert("Erreur: " + err.message);
}
};
window.populateCalendarSettings = function (settings) {
if (!settings) return;
const viewSelect = document.getElementById("setCalView");
if (viewSelect && settings.calendar_default_view)
viewSelect.value = settings.calendar_default_view;
const firstDaySelect = document.getElementById("setCalFirstDay");
if (firstDaySelect && settings.calendar_first_day !== undefined)
firstDaySelect.value = settings.calendar_first_day;
const hoursInput = document.getElementById("setCalHours");
if (hoursInput && settings.calendar_working_hours)
hoursInput.value = settings.calendar_working_hours;
};
// ==========================================
// NOUVEAU CATALOGUE GLOBAL (FOYER)
// ==========================================
let globalLeaveCatalog = [];
window.loadAdultConfigView = function (memberId, zoneElement) {
let userLeaves = calGlobalData.leaves[memberId] || [];
const tableRows = userLeaves
.map((l) => {
let dateDisp = "";
if (l.date) {
const parts = l.date.split("-");
if (parts.length >= 3) dateDisp = `${parts[2]}/${parts[1]}`;
async function loadLeaveCatalog() {
try {
const res = await pachaFetch(
"/modules/family-calendar/includes/api/calendar-settings.php?action=get_leave_types",
);
globalLeaveCatalog = res.data || [];
renderLeaveCatalog(globalLeaveCatalog);
} catch (err) {
console.error("Erreur chargement catalogue", err);
}
}
let rowTypeOptions = (calGlobalData.leave_types || [])
.map(
(lt) =>
`<option value="${lt.code}" ${lt.code === l.type ? "selected" : ""}>${lt.code} - ${lt.label}</option>`,
)
.join("");
function renderLeaveCatalog(types) {
const container = document.getElementById("leaveTypesContainer");
if (!container) return;
container.innerHTML = "";
if (!calGlobalData.leave_types.some((lt) => lt.code === l.type)) {
rowTypeOptions += `<option value="${l.type}" selected>${l.type} (${tr("fc_leave_obsolete") || "Obsolète"})</option>`;
if (types.length === 0) {
container.innerHTML = `<p class="pf-muted-note">Aucun congé configuré.</p>`;
return;
}
return `
<tr class="js-leave-row" style="border-bottom: 1px solid var(--border-light);">
<td style="padding: 6px 2px;">
<select class="pf-input js-leave-type" required style="width:100%; padding:6px 2px; font-size:0.8rem; box-sizing: border-box;">
<option value="">${tr("fc_leave_code_placeholder") || "Code..."}</option>
${rowTypeOptions}
</select>
</td>
<td style="padding: 6px 2px;">
<select class="pf-input js-leave-method" style="width:100%; padding:6px 2px; font-size:0.8rem; box-sizing: border-box;">
<option value="FIXED" ${l.method === "FIXED" ? "selected" : ""}>${tr("fc_leave_method_fixed") || "Fixe"}</option>
<option value="ACCUMULATED" ${l.method === "ACCUMULATED" ? "selected" : ""}>${tr("fc_leave_method_accumulated") || "Cumul"}</option>
</select>
</td>
<td style="padding: 6px 2px;"><input type="number" step="0.5" class="pf-input js-leave-allowance" value="${l.allowance || 0}" placeholder="0" required style="width:100%; padding:6px 4px; font-size:0.85rem; text-align:center; box-sizing: border-box;"></td>
<td style="padding: 6px 2px;"><input type="text" class="pf-input js-leave-date" value="${dateDisp}" placeholder="${tr("fc_date_format_placeholder") || "JJ/MM"}" pattern="(0[1-9]|[12][0-9]|3[01])/(0[1-9]|1[012])" maxlength="5" required style="width:100%; padding:6px 4px; font-size:0.85rem; text-align:center; box-sizing: border-box;" title="${tr("fc_date_format_title") || "Format JJ/MM"}"></td>
<td style="padding: 6px 2px; text-align:right;"><button type="button" class="btn-icon-action delete" style="padding:4px; margin:0;" onclick="this.closest('tr').remove()" title="${tr("btn_delete") || "Supprimer"}">🗑️</button></td>
</tr>
`;
})
.join("");
zoneElement.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:12px;">
types.forEach((lt) => {
container.innerHTML += `
<div style="display: flex; justify-content: space-between; align-items: center; background: var(--bg-page); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border-light); margin-bottom: 6px;">
<div>
<h5 style="margin:0 0 4px 0; color:var(--text-main); font-size:1rem;">🗓️ ${tr("fc_matrix_title") || "Matrice des Congés"}</h5>
<p style="font-size:0.8rem; color:var(--text-muted); margin:0; line-height:1.3;">${tr("fc_matrix_desc") || "Définissez les quotas et la date de renouvellement."}</p>
<span style="font-weight: bold; color: var(--text-main);">${lt.label}</span>
<span style="background: var(--bg-subtle); border: 1px solid var(--border-main); border-radius: 4px; padding: 2px 6px; font-size: 0.8rem; margin-left: 8px; color: var(--text-muted);">${lt.code}</span>
</div>
<button type="button" class="pf-btn pf-btn-secondary" style="padding:6px 10px; font-size:0.8rem; white-space:nowrap;" onclick="addLeaveRowToMatrix()"> ${tr("btn_add") || "Ajouter"}</button>
<div style="display: flex; gap: 4px;">
<button type="button" class="pf-btn btn-secondary" style="padding: 4px 8px;" onclick="editLeaveType('${lt.code}', '${lt.label.replace(/'/g, "\\'")}')">✏️</button>
<button type="button" class="pf-btn btn-secondary" style="padding: 4px 8px; color: var(--danger);" onclick="deleteLeaveType('${lt.code}', '${lt.label.replace(/'/g, "\\'")}')">🗑️</button>
</div>
<div style="overflow-x:hidden; margin: 0 -5px;">
<table style="width:100%; border-collapse:collapse; margin-bottom:15px; table-layout: fixed;">
<thead>
<tr style="font-size:0.75rem; text-align:left; color:var(--text-muted); text-transform:uppercase;">
<th style="padding:0 2px 8px; width: 33%;">${tr("fc_table_code") || "Code"}</th>
<th style="padding:0 2px 8px; width: 22%;">${tr("fc_table_method") || "Méthode"}</th>
<th style="padding:0 2px 8px; width: 15%;">${tr("fc_table_quota") || "Quota"}</th>
<th style="padding:0 2px 8px; width: 20%;">${tr("fc_table_renewal") || "Renouv."}</th>
<th style="padding:0 2px 8px; width: 10%;"></th>
</tr>
</thead>
<tbody id="tbodyLeaveMatrix">
${tableRows || `<tr><td colspan="5" id="emptyMatrixRow" style="text-align:center; padding:20px; color:var(--text-muted); font-style:italic; font-size:0.9rem;">${tr("fc_matrix_empty") || "Aucun compteur défini."}</td></tr>`}
</tbody>
</table>
</div>
<div style="display:flex; justify-content:flex-end; border-top:1px solid var(--border-light); padding-top:12px;">
<button type="button" class="pf-btn pf-btn-primary" style="padding:8px 16px;" onclick="submitMemberLeaves()">${tr("btn_save") || "Enregistrer"}</button>
</div>
`;
};
});
}
window.loadMemberConfigView = function () {
function editLeaveType(code, label) {
document.getElementById("leaveTypeFormTitle").innerText =
"✏️ Modifier : " + label;
document.getElementById("lt-mode").value = "edit";
const codeInput = document.getElementById("lt-code");
codeInput.value = code;
codeInput.readOnly = true;
codeInput.style.backgroundColor = "var(--bg-subtle)";
codeInput.style.opacity = "0.6";
codeInput.style.cursor = "not-allowed";
document.getElementById("lt-code-note").innerText = "Non modifiable";
document.getElementById("lt-label").value = label;
}
function resetLeaveTypeForm() {
document.getElementById("leaveTypeFormTitle").innerText = "+ Ajouter";
document.getElementById("lt-mode").value = "add";
const codeInput = document.getElementById("lt-code");
codeInput.value = "";
codeInput.readOnly = false;
codeInput.style.backgroundColor = "";
codeInput.style.opacity = "1";
codeInput.style.cursor = "text";
document.getElementById("lt-code-note").innerText = "Irréversible";
document.getElementById("lt-label").value = "";
}
// NOUVELLE FONCTION DE SUPPRESSION
async function deleteLeaveType(code, label) {
if (
!confirm(
`Supprimer définitivement le congé "${label}" du catalogue ? Cela le retirera également des membres qui l'utilisent.`,
)
)
return;
const fd = new FormData();
fd.append("action", "delete_leave_type");
fd.append("code", code);
try {
const res = await pachaFetch(
"/modules/family-calendar/includes/api/calendar-settings.php",
{ method: "POST", body: fd },
);
if (!res.success) throw new Error(res.error);
if (window.showToast) showToast("Congé supprimé avec succès !", "success");
resetLeaveTypeForm();
loadLeaveCatalog();
// On recharge l'onglet membre au cas où le membre affiché utilisait ce congé
if (document.getElementById("selectCalMember").value) {
loadMemberConfigView();
}
} catch (err) {
alert("Erreur de suppression : " + err.message);
}
}
async function saveLeaveType() {
const code = document.getElementById("lt-code").value.toUpperCase();
const label = document.getElementById("lt-label").value;
if (!code || !label)
return window.showToast
? showToast("Code et Label requis", "error")
: alert("Code et Label requis");
const fd = new FormData();
fd.append("action", "save_leave_type");
fd.append("mode", document.getElementById("lt-mode").value);
fd.append("code", code);
fd.append("label", label);
try {
const res = await pachaFetch(
"/modules/family-calendar/includes/api/calendar-settings.php",
{ method: "POST", body: fd },
);
if (!res.success) throw new Error(res.error);
if (window.showToast) showToast("Catalogue mis à jour !", "success");
resetLeaveTypeForm();
loadLeaveCatalog();
} catch (err) {
alert("Erreur d'enregistrement : " + err.message);
}
}
// ==========================================
// MODALE SETTINGS : AFFECTATION (MEMBRES)
// ==========================================
// Quand on choisit un membre dans la liste déroulante
async function loadMemberConfigView() {
const select = document.getElementById("selectCalMember");
const zone = document.getElementById("memberConfigZone");
if (!select || !select.value || !zone) return;
window.currentSelectedMemberId = parseInt(select.value);
const personId = parseInt(select.value);
window.currentSelectedMemberId = personId;
const role = (
select.options[select.selectedIndex].dataset.role || ""
).toLowerCase();
zone.innerHTML = '<p class="pf-muted-note">Chargement...</p>';
// === CAS 1 : C'EST UN ENFANT (On gère les modes de garde) ===
if (role === "child" || role === "enfant") {
const currentPerson = calGlobalData.people.find(
(k) => parseInt(k.id) === window.currentSelectedMemberId,
(k) => parseInt(k.id) === personId,
);
let savedModes = [];
try {
@@ -303,74 +367,136 @@ window.loadMemberConfigView = function () {
</div>
<button class="pf-btn pf-btn-primary" style="margin-top:15px; width:100%;" onclick="submitChildCareModes()">${tr("btn_save_rights") || "Enregistrer"}</button>
`;
} else {
window.loadAdultConfigView(window.currentSelectedMemberId, zone);
}
};
window.submitChildCareModes = async function () {
if (!window.currentSelectedMemberId) return;
const checkboxes = document.querySelectorAll(".js-care-mode-cb:checked");
const selectedModes = Array.from(checkboxes).map((cb) => cb.value);
// === CAS 2 : C'EST UN ADULTE (On gère les congés) ===
else {
try {
const formData = new FormData();
formData.append("action", "save_child_care_modes");
formData.append("person_id", window.currentSelectedMemberId);
formData.append("care_modes", JSON.stringify(selectedModes));
const res = await pachaFetch(
"/modules/family-calendar/includes/api/settings.php",
{ method: "POST", body: formData },
`/modules/family-calendar/includes/api/calendar-settings.php?action=get_person_leaves&person_id=${personId}`,
);
const memberLeaves = res.data || [];
renderMemberLeavesView(personId, memberLeaves);
} catch (err) {
zone.innerHTML =
'<p class="pf-muted-note" style="color:var(--danger);">Erreur de chargement.</p>';
}
}
}
// Affichage des congés du membre et du formulaire d'ajout
function renderMemberLeavesView(personId, memberLeaves) {
const zone = document.getElementById("memberConfigZone");
let html = `<h5 style="margin: 0 0 10px 0; color: var(--text-main);">Congés attribués</h5>`;
if (memberLeaves.length === 0) {
html += `<p class="pf-muted-note" style="font-size: 0.85rem;">Aucun congé attribué à ce membre.</p>`;
} else {
html += `<div style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px;">`;
memberLeaves.forEach((ml) => {
// On extrait le mois de la date anniversaire (ex: "2000-06-01" -> 6)
const moisRenouv = ml.anniversary_date
? parseInt(ml.anniversary_date.split("-")[1])
: 1;
html += `
<div style="display: flex; justify-content: space-between; align-items: center; background: var(--bg-subtle); padding: 8px; border-radius: 6px; border: 1px solid var(--border-light);">
<div>
<strong style="color: var(--text-main);">${ml.leave_type}</strong>
<span style="font-size: 0.8rem; color: var(--text-muted); margin-left: 5px;">(Quota: <b>${ml.allowance}j</b> - Renouv: <b>Mois ${moisRenouv}</b>)</span>
</div>
<button class="pf-btn btn-secondary" style="padding: 2px 6px; color: var(--danger);" onclick="deleteMemberLeave(${ml.id}, ${personId})">🗑️</button>
</div>
`;
});
html += `</div>`;
}
// Ajout du selecteur de mois dans l'attribution
html += `
<hr style="border: 0; border-top: 1px solid var(--border-light); margin: 15px 0;">
<h5 style="margin: 0 0 10px 0;">+ Attribuer un congé</h5>
<div style="display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap;">
<div style="flex: 1; min-width: 120px;">
<label class="pf-label" style="font-size: 0.8rem;">Type de congé</label>
<select id="new-member-leave-type" class="pf-input" style="width: 100%;">
<option value="" disabled selected>-- Sélectionner --</option>
${globalLeaveCatalog.map((lt) => `<option value="${lt.code}">${lt.label}</option>`).join("")}
</select>
</div>
<div style="flex: 0 0 70px;">
<label class="pf-label" style="font-size: 0.8rem;">Quota</label>
<input type="number" step="0.5" id="new-member-leave-allowance" class="pf-input" value="0" style="width: 100%;">
</div>
<div style="flex: 0 0 100px;">
<label class="pf-label" style="font-size: 0.8rem;">Renouvellement</label>
<select id="new-member-leave-reset" class="pf-input" style="width: 100%;">
<option value="1">${tr("month_jan") || "Janvier"}</option>
<option value="2">${tr("month_feb") || "Février"}</option>
<option value="3">${tr("month_mar") || "Mars"}</option>
<option value="4">${tr("month_apr") || "Avril"}</option>
<option value="5">${tr("month_may") || "Mai"}</option>
<option value="6">${tr("month_jun") || "Juin"}</option>
<option value="7">${tr("month_jul") || "Juillet"}</option>
<option value="8">${tr("month_aug") || "Août"}</option>
<option value="9">${tr("month_sep") || "Septembre"}</option>
<option value="10">${tr("month_oct") || "Octobre"}</option>
<option value="11">${tr("month_nov") || "Novembre"}</option>
<option value="12">${tr("month_dec") || "Décembre"}</option>
</select>
</div>
<button class="pf-btn pf-btn-primary" onclick="addMemberLeave(${personId})">Ajouter</button>
</div>
`;
zone.innerHTML = html;
}
async function addMemberLeave(personId) {
const leaveCode = document.getElementById("new-member-leave-type").value;
const allowance = document.getElementById("new-member-leave-allowance").value;
const resetMonth = document.getElementById("new-member-leave-reset").value;
if (!leaveCode) return showToast("Veuillez sélectionner un type", "error");
const fd = new FormData();
fd.append("action", "add_person_leave");
fd.append("person_id", personId);
fd.append("leave_type", leaveCode);
fd.append("allowance", allowance);
fd.append("reset_month", resetMonth);
try {
const res = await pachaFetch(
"/modules/family-calendar/includes/api/calendar-settings.php",
{ method: "POST", body: fd },
);
if (!res.success) throw new Error(res.error);
if (window.showToast)
showToast(tr("fc_child_saved") || "Sauvegardé !", "success");
else alert(tr("fc_child_saved") || "Sauvegardé !");
const currentPerson = calGlobalData.people.find(
(k) => parseInt(k.id) === window.currentSelectedMemberId,
);
if (currentPerson) {
currentPerson.care_modes = JSON.stringify(selectedModes);
}
if (window.showToast) showToast("Congé attribué avec succès !", "success");
loadMemberConfigView();
} catch (err) {
alert("Erreur: " + err.message);
}
};
async function submitMemberLeaves() {
if (!currentSelectedMemberId || isNaN(currentSelectedMemberId)) {
alert("Erreur technique : Aucun membre valide sélectionné.");
return;
}
// Supprimer un congé d'un membre
async function deleteMemberLeave(leaveId, personId) {
if (!confirm(tr("confirm_delete") || "Retirer ce congé pour ce membre ?"))
return;
const fd = new FormData();
fd.append("action", "delete_person_leave");
fd.append("id", leaveId);
try {
const rows = document.querySelectorAll(".js-leave-row");
const leavesPayload = Array.from(rows)
.map((row) => ({
type: row.querySelector(".js-leave-type").value.trim().toUpperCase(),
method: row.querySelector(".js-leave-method").value,
allowance: row.querySelector(".js-leave-allowance").value,
date: row.querySelector(".js-leave-date").value,
}))
.filter((l) => l.type && l.date);
const formData = new FormData();
formData.append("action", "save_member_leaves");
formData.append("person_id", currentSelectedMemberId);
formData.append("leaves", JSON.stringify(leavesPayload));
const res = await pachaFetch(
"/modules/family-calendar/includes/api/settings.php",
{ method: "POST", body: formData },
"/modules/family-calendar/includes/api/calendar-settings.php",
{ method: "POST", body: fd },
);
if (!res.success) throw new Error(res.error);
if (window.showToast)
showToast(tr("fc_matrix_saved") || "Matrice enregistrée", "success");
else alert(tr("fc_matrix_saved") || "Matrice enregistrée");
closeCalendarSettings();
setTimeout(openCalendarSettings, 300);
if (window.showToast) showToast("Congé retiré !", "success");
loadMemberConfigView();
} catch (err) {
alert("Erreur: " + err.message);
}
@@ -450,7 +576,7 @@ document.addEventListener("DOMContentLoaded", () => {
try {
const res = await pachaFetch(
"/modules/family-calendar/includes/api/settings.php?action=get_all",
"/modules/family-calendar/includes/api/calendar-settings.php?action=get_all",
);
if (res && res.success && res.data) {
window.calGlobalData = res.data;
@@ -7,19 +7,14 @@ header('Content-Type: application/json');
$action = $_REQUEST['action'] ?? '';
try {
// ─── 1. RECUPÉRATION COMPLÈTE DES PARAMÈTRES ───
// ─── 1. RECUPÉRATION COMPLÈTE DES PARAMÈTRES (INITIALISATION) ───
if ($action === 'get_all') {
// A. Options globales du foyer
$stmtFoyer = $pdo->query("SELECT zone_scolaire, care_modes FROM pf_foyer_settings LIMIT 1");
// On retire le fallback "Nounou" codé en dur, on part sur vide si rien n'est configuré
$foyer = $stmtFoyer->fetch(PDO::FETCH_ASSOC) ?: ['zone_scolaire' => 'C', 'care_modes' => '[]'];
// B. Liste des membres de la famille (Triés par rôle pour grouper Parents, Enfants, Helpers)
// 🟢 CORRECTION : Ajout de la colonne `care_modes` et `color` pour le Javascript !
$stmtPeople = $pdo->query("SELECT id, name, role, care_modes, color FROM pf_people WHERE is_active = 1 ORDER BY role ASC, name ASC");
$people = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
// C. Matrice des congés par personne
$stmtLeaves = $pdo->query("SELECT person_id, leave_type, anniversary_date, method, allowance FROM pf_person_leave_meta");
$leavesRaw = $stmtLeaves->fetchAll(PDO::FETCH_ASSOC);
@@ -33,18 +28,16 @@ try {
];
}
// D. Paramètres dynamiques du calendrier (pf_settings)
$stmtSettings = $pdo->query("SELECT setting_key, setting_value FROM pf_settings WHERE module = 'calendar'");
$calendarSettingsRaw = $stmtSettings->fetchAll(PDO::FETCH_KEY_PAIR); // Crée un tableau [key => value]
$calendarSettingsRaw = $stmtSettings->fetchAll(PDO::FETCH_KEY_PAIR);
// Paramètres par défaut si la table est vide
$calendarSettings = [
'calendar_default_view' => $calendarSettingsRaw['calendar_default_view'] ?? 'month',
'calendar_first_day' => $calendarSettingsRaw['calendar_first_day'] ?? '1',
'calendar_working_hours' => $calendarSettingsRaw['calendar_working_hours'] ?? '08:00-19:00'
];
// E. NOUVEAU : Récupération du catalogue des types de congés de la famille
// Catalogue des congés
$stmtLeaveTypes = $pdo->query("SELECT code, label, default_allowance, reset_month, allow_carry_over FROM pf_leave_types ORDER BY label ASC");
$leaveTypes = $stmtLeaveTypes->fetchAll(PDO::FETCH_ASSOC);
@@ -58,7 +51,7 @@ try {
'people' => $people,
'leaves' => $leavesMap,
'calendar_settings' => $calendarSettings,
'leave_types' => $leaveTypes // On envoie le catalogue au JS !
'leave_types' => $leaveTypes
]
]);
exit;
@@ -70,7 +63,6 @@ try {
$zone = trim($_POST['zone_scolaire'] ?? 'C');
$modesRaw = $_POST['care_modes'] ?? '[]';
$modesArray = json_encode(array_values(array_unique(array_filter(json_decode($modesRaw, true)))));
$stmt = $pdo->prepare("UPDATE pf_foyer_settings SET zone_scolaire = ?, care_modes = ? WHERE id = 1");
@@ -80,74 +72,7 @@ try {
exit;
}
// ─── 3. SAUVEGARDE DES CONGÉS D'UN MEMBRE (ONGLET 2) ───
if ($action === 'save_member_leaves') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$personId = (int)($_POST['person_id'] ?? 0);
$leavesData = json_decode($_POST['leaves'] ?? '[]', true);
if ($personId <= 0) throw new Exception("Identifiant de membre invalide.");
$pdo->beginTransaction();
$stmtDelete = $pdo->prepare("DELETE FROM pf_person_leave_meta WHERE person_id = ?");
$stmtDelete->execute([$personId]);
if (!empty($leavesData)) {
$stmtInsert = $pdo->prepare("INSERT INTO pf_person_leave_meta (person_id, leave_type, method, allowance, anniversary_date) VALUES (?, ?, ?, ?, ?)");
foreach ($leavesData as $leave) {
$type = strtoupper(trim($leave['type']));
$method = in_array($leave['method'] ?? '', ['FIXED', 'ACCUMULATED']) ? $leave['method'] : 'FIXED';
$allowance = (float)($leave['allowance'] ?? 0);
$date = trim($leave['date']);
// Si le JS n'envoie que "MM-DD" (Renouvellement perpétuel), on ajoute l'année bissextile 2000 pour satisfaire le format DATE de MySQL
if (preg_match('/^\d{2}-\d{2}$/', $date)) {
$date = "2000-" . $date;
}
if (!empty($type) && !empty($date)) {
$stmtInsert->execute([$personId, $type, $method, $allowance, $date]);
}
}
}
$pdo->commit();
echo json_encode(['success' => true]);
exit;
}
// ─── 4. SAUVEGARDE DES PARAMÈTRES D'AFFICHAGE DU CALENDRIER (ONGLET 3) ───
if ($action === 'save_calendar_settings') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$allowed_keys = ['calendar_default_view', 'calendar_first_day', 'calendar_working_hours'];
$stmt = $pdo->prepare("
INSERT INTO pf_settings (setting_key, setting_value, module)
VALUES (:key, :val, 'calendar')
ON DUPLICATE KEY UPDATE setting_value = :val2
");
$pdo->beginTransaction();
foreach ($allowed_keys as $key) {
if (isset($_POST[$key])) {
$value = trim($_POST[$key]);
$stmt->execute([
'key' => $key,
'val' => $value,
'val2' => $value
]);
}
}
$pdo->commit();
echo json_encode(['success' => true]);
exit;
}
// ─── 5. SAUVEGARDE DES MODES DE GARDE D'UN ENFANT ───
// ─── 3. SAUVEGARDE DES MODES DE GARDE D'UN ENFANT ───
if ($action === 'save_child_care_modes') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
@@ -164,7 +89,100 @@ try {
exit;
}
throw new Exception("Action inconnue");
// ─── 4. GESTION DU CATALOGUE DE CONGÉS (FOYER) ───
if ($action === 'get_leave_types') {
$stmt = $pdo->query("SELECT * FROM pf_leave_types ORDER BY label ASC");
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
if ($action === 'save_leave_type') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$mode = $_POST['mode'] ?? 'add';
$code = strtoupper(trim($_POST['code'] ?? ''));
$label = trim($_POST['label'] ?? '');
if (empty($code) || empty($label)) throw new Exception("Le code et le label sont obligatoires.");
if ($mode === 'add') {
$stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label) VALUES (?, ?)");
$stmt->execute([$code, $label]);
} else {
$stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ? WHERE code = ?");
$stmt->execute([$label, $code]);
}
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_leave_type') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$code = strtoupper(trim($_POST['code'] ?? ''));
if (empty($code)) throw new Exception("Code du congé manquant.");
$pdo->beginTransaction();
// 1. On supprime d'abord ce congé chez tous les membres à qui on l'avait attribué
$stmtMeta = $pdo->prepare("DELETE FROM pf_person_leave_meta WHERE leave_type = ?");
$stmtMeta->execute([$code]);
// 2. Ensuite, on supprime le congé du catalogue global
$stmtCat = $pdo->prepare("DELETE FROM pf_leave_types WHERE code = ?");
$stmtCat->execute([$code]);
$pdo->commit();
echo json_encode(['success' => true]);
exit;
}
// ─── 5. GESTION DES CONGÉS INDIVIDUELS (MEMBRES) ───
if ($action === 'get_person_leaves') {
$personId = (int)($_GET['person_id'] ?? 0);
$stmt = $pdo->prepare("SELECT id, leave_type, allowance, anniversary_date FROM pf_person_leave_meta WHERE person_id = ? ORDER BY leave_type ASC");
$stmt->execute([$personId]);
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
if ($action === 'add_person_leave') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$personId = (int)($_POST['person_id'] ?? 0);
$type = strtoupper(trim($_POST['leave_type'] ?? ''));
$allowance = (float)($_POST['allowance'] ?? 0);
$resetMonth = (int)($_POST['reset_month'] ?? 1);
if ($personId <= 0 || empty($type)) throw new Exception("Données invalides.");
$check = $pdo->prepare("SELECT id FROM pf_person_leave_meta WHERE person_id = ? AND leave_type = ?");
$check->execute([$personId, $type]);
if ($check->rowCount() > 0) throw new Exception("Ce congé est déjà attribué à cette personne.");
// On construit la date anniversaire avec le mois choisi par l'utilisateur
$anniversaryDate = "2000-" . str_pad((string)$resetMonth, 2, "0", STR_PAD_LEFT) . "-01";
$stmt = $pdo->prepare("INSERT INTO pf_person_leave_meta (person_id, leave_type, allowance, method, anniversary_date) VALUES (?, ?, ?, 'FIXED', ?)");
$stmt->execute([$personId, $type, $allowance, $anniversaryDate]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_person_leave') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$id = (int)($_POST['id'] ?? 0);
$stmt = $pdo->prepare("DELETE FROM pf_person_leave_meta WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
throw new Exception("Action inconnue : " . htmlspecialchars($action));
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
+64 -31
View File
@@ -1,51 +1,84 @@
<?php
require_once __DIR__ . '/../../includes/db.php';
require __DIR__ . '/../../includes/auth.php';
require __DIR__ . '/../../includes/db.php';
require_login();
header('Content-Type: application/json');
$action = $_POST['action'] ?? $_GET['action'] ?? '';
try {
// 1. Lire toutes les fêtes
if ($action === 'get_occasions') {
$stmt = $pdo->query("SELECT * FROM pf_gift_occasions ORDER BY month_date ASC, id ASC");
echo json_encode(['ok' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
if ($action === 'get_all') {
$occ = $pdo->query("SELECT * FROM pf_gift_occasions ORDER BY sort_order ASC, id ASC")->fetchAll();
$proches = $pdo->query("SELECT * FROM pf_people WHERE role IN ('proche_adulte', 'proche_enfant') ORDER BY name ASC")->fetchAll();
echo json_encode(['success' => true, 'data' => ['occasions' => $occ, 'proches' => $proches]]);
exit;
}
// 2. Ajouter une nouvelle fête (Renvoie l'ID pour rafraîchir la modale)
if ($action === 'add_occasion') {
$name = trim($_POST['name'] ?? '');
$month_date = trim($_POST['month_date'] ?? '');
if ($action === 'save_occasion') {
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$name = trim($_POST['name']);
$icon = trim($_POST['icon']) ?: '🎁';
$month_date = trim($_POST['month_date']) ?: null;
if (empty($name)) throw new Exception("Name required");
if (empty($name)) throw new Exception("Le nom est obligatoire.");
$code = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $name));
$code = substr($code, 0, 20);
$stmt = $pdo->prepare("INSERT INTO pf_gift_occasions (code, name, month_date, is_active) VALUES (?, ?, ?, 1)");
$stmt->execute([$code, $name, $month_date ?: null]);
echo json_encode(['ok' => true]);
if ($id) {
$stmt = $pdo->prepare("UPDATE pf_gift_occasions SET name=?, icon=?, month_date=? WHERE id=?");
$stmt->execute([$name, $icon, $month_date, $id]);
} else {
$code = 'CUST_' . strtoupper(substr(preg_replace('/[^A-Za-z0-9]/', '', $name), 0, 4)) . '_' . rand(100, 999);
$stmt = $pdo->prepare("INSERT INTO pf_gift_occasions (code, name, icon, month_date) VALUES (?, ?, ?, ?)");
$stmt->execute([$code, $name, $icon, $month_date]);
}
echo json_encode(['success' => true]);
exit;
}
// 3. Sauvegarde globale des cases à cocher
if ($action === 'save_toggles') {
$states = json_decode($_POST['states'] ?? '[]', true);
if (is_array($states)) {
$stmt = $pdo->prepare("UPDATE pf_gift_occasions SET is_active = ? WHERE id = ?");
foreach ($states as $item) {
$stmt->execute([(int)$item['state'], (int)$item['id']]);
}
}
echo json_encode(['ok' => true]);
if ($action === 'delete_occasion') {
$id = (int)$_POST['id'];
$pdo->prepare("DELETE FROM pf_gift_occasions WHERE id=?")->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
throw new Exception("Action inconnue.");
if ($action === 'toggle_occasion') {
$id = (int)$_POST['id'];
$state = (int)$_POST['state'];
$pdo->prepare("UPDATE pf_gift_occasions SET is_active=? WHERE id=?")->execute([$state, $id]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'save_proche') {
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$name = trim($_POST['name']);
$role = $_POST['role'];
if (empty($name)) throw new Exception("Le nom est obligatoire.");
if (!in_array($role, ['proche_adulte', 'proche_enfant'])) throw new Exception("Rôle invalide.");
if ($id) {
// Sécurité : on s'assure qu'on modifie bien un proche et pas un parent
$stmt = $pdo->prepare("UPDATE pf_people SET name=?, role=? WHERE id=? AND role IN ('proche_adulte', 'proche_enfant')");
$stmt->execute([$name, $role, $id]);
} else {
$stmt = $pdo->prepare("INSERT INTO pf_people (name, role, color) VALUES (?, ?, '#94a3b8')");
$stmt->execute([$name, $role]);
}
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_proche') {
$id = (int)$_POST['id'];
$stmt = $pdo->prepare("DELETE FROM pf_people WHERE id=? AND role IN ('proche_adulte', 'proche_enfant')");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
}
?>
+161 -339
View File
@@ -6,7 +6,6 @@
margin: 0;
color: var(--text-main);
}
.cl-titlebar {
display: flex;
flex-wrap: wrap;
@@ -17,7 +16,6 @@
border-bottom: 1px solid var(--border-light);
padding-bottom: 15px;
}
.cl-view-switch {
display: flex;
padding: 4px;
@@ -25,7 +23,6 @@
border: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
}
.cl-view-btn {
padding: 6px 16px;
border-radius: 6px;
@@ -35,13 +32,12 @@
color: var(--text-muted);
transition: 0.2s;
}
.cl-view-btn.is-active {
background: var(--text-main);
color: var(--bg-panel);
}
/* === FILTRES STICKY === */
/* === FILTRES === */
.pf-filter-bar {
display: flex;
gap: 10px;
@@ -52,7 +48,6 @@
z-index: 50;
flex-wrap: wrap;
}
.pf-filter-label {
font-size: 0.85rem;
font-weight: 600;
@@ -62,35 +57,6 @@
gap: 6px;
}
.pf-filter-select {
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--border-light);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
font-size: 0.85rem;
font-weight: 600;
color: var(--text-main);
outline: none;
font-family: inherit;
cursor: pointer;
box-shadow: var(--shadow-sm);
width: auto;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px;
padding-right: 28px;
}
.pf-filter-select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
/* === SECTIONS ENFANTS === */
.pf-child-section {
margin-bottom: 30px;
@@ -226,18 +192,69 @@
z-index: 2;
border-right: 2px solid var(--border-light);
}
.cl-occasion-icon {
width: 20px;
height: 20px;
object-fit: contain;
vertical-align: middle;
.gift-tricount-section {
border: 1px solid var(--border-light);
margin-top: 40px;
}
.gift-tricount-title {
margin-top: 0;
color: var(--text-main);
font-size: 1.3rem;
}
.gift-tricount-success {
color: var(--success);
font-weight: 700;
margin-bottom: 0;
}
.gift-tricount-debt-amount {
color: var(--danger);
}
.gift-matrix-details {
margin-top: 20px;
padding: 12px;
border-radius: 8px;
background: var(--bg-panel);
border: 1px solid var(--border-light);
}
.gift-matrix-summary {
cursor: pointer;
font-weight: 600;
color: var(--text-muted);
outline: none;
}
.gift-matrix-wrapper {
overflow-x: auto;
margin-top: 15px;
}
.cl-debt-matrix th.sticky-col.bg-header {
background: var(--bg-page);
}
.cl-debt-matrix th.sticky-col.bg-body {
background: var(--bg-panel);
}
.gift-cell-danger {
color: var(--danger);
font-weight: 700;
background: var(--bg-danger-soft);
}
.gift-cell-muted {
color: var(--text-muted);
}
/* === TYPOGRAPHIE & ICONES === */
.cl-occasion-icon {
font-size: 1.4rem;
margin-right: 8px;
}
.cl-occasion-title {
display: flex;
align-items: center;
gap: 8px;
}
.gift-empty-state {
color: var(--text-muted);
font-size: 0.9rem;
font-style: italic;
margin: 0;
}
/* === COMPOSANT MULTI-SELECT VANILLA === */
@@ -355,79 +372,7 @@
}
}
/* Header & Settings Button */
.gift-settings-btn {
font-size: 1.5rem;
background: none;
border: none;
cursor: pointer;
}
/* Empty States */
.gift-empty-state {
color: var(--text-muted);
font-size: 0.9rem;
font-style: italic;
margin: 0;
}
/* Section Tricount (Bilan) */
.gift-tricount-section {
border: 1px solid var(--border-light);
margin-top: 40px;
}
.gift-tricount-title {
margin-top: 0;
color: var(--text-main);
font-size: 1.3rem;
}
.gift-tricount-success {
color: var(--success);
font-weight: 700;
margin-bottom: 0;
}
.gift-tricount-debt-amount {
color: var(--danger);
}
.gift-matrix-details {
margin-top: 20px;
padding: 12px;
border-radius: 8px;
}
.gift-matrix-summary {
cursor: pointer;
font-weight: 600;
color: var(--text-muted);
outline: none;
}
.gift-matrix-wrapper {
overflow-x: auto;
margin-top: 15px;
}
.cl-debt-matrix th.sticky-col,
.cl-debt-matrix td.sticky-col {
position: sticky;
left: 0;
z-index: 2;
border-right: 2px solid #e2e8f0;
}
.cl-debt-matrix th.sticky-col.bg-header {
background: #f8fafc;
}
.cl-debt-matrix th.sticky-col.bg-body {
background: white;
}
.gift-cell-danger {
color: var(--danger);
font-weight: 700;
background: #fef2f2;
}
.gift-cell-muted {
color: var(--text-muted);
}
/* Modale Edition Cadeau */
/* === MODALE EDITION CADEAU === */
.gift-modal-custom-content {
max-width: 500px;
width: 95%;
@@ -459,249 +404,126 @@
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid var(--border-light);
}
/* Modale Paramètres Occasions (Bottom Sheet style) */
.gift-settings-backdrop {
.gl-pane {
display: none;
align-items: flex-end; /* Bottom sheet on mobile */
animation: fadeIn 0.3s ease;
}
.gift-settings-modal {
width: 100%;
border-radius: 12px 12px 0 0;
margin: 0;
.gl-pane.active {
display: block;
}
.gift-settings-header {
display: flex;
justify-content: space-between;
align-items: center;
.gl-pane-title {
margin-top: 0;
color: var(--text-main);
font-size: 1.1rem;
margin-bottom: 10px;
}
.gift-settings-header h3 {
margin: 0;
}
.gift-settings-body {
padding: 1rem;
}
.gift-occasions-list {
margin-bottom: 2rem;
}
.gift-occasions-grid {
display: grid;
gap: 0.5rem;
}
.gift-occasion-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem 1rem;
border: 1px solid var(--border-light);
border-radius: 8px;
}
.gift-occasion-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.gift-date-badge {
font-size: 0.8em;
background: var(--bg-page);
padding: 2px 6px;
border-radius: 4px;
margin-left: 10px;
}
.gift-add-subtitle {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.gift-add-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.gift-add-input-name {
flex: 1;
min-width: 150px;
}
.gift-add-input-date {
width: 120px;
}
.gift-help-text {
.gl-pane-desc {
font-size: 0.85rem;
color: var(--text-muted);
font-size: 0.85em;
margin-bottom: 15px;
}
.gl-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--bg-panel);
border-radius: var(--radius);
border: 1px solid var(--border-light);
margin-bottom: 8px;
}
.gl-form-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: flex-end;
margin-top: 15px;
}
.gl-divider {
margin: 1.5rem 0;
border: 0;
border-bottom: 1px solid var(--border-light);
}
.gl-input-sm {
flex: 0 0 60px;
}
.gl-input-md {
flex: 1;
min-width: 120px;
}
.gift-loader {
text-align: center;
padding: 20px;
}
.gift-error-msg {
color: var(--danger);
padding: 10px;
}
/* ==========================================================================
BOUTONS & MODALE (Design System Todo)
========================================================================== */
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.85rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
white-space: nowrap;
}
.btn-icon {
padding: 0.25rem;
min-width: 28px;
justify-content: center;
font-size: 1.2rem;
}
.btn-ghost {
background: transparent;
border: none;
color: var(--text-muted);
font-style: italic;
}
.btn-ghost:hover {
color: var(--text-main);
background: var(--bg-page);
border-radius: 6px;
}
/* Correction de la surimpression de la modale Paramètres */
.gift-settings-backdrop {
display: none;
position: fixed;
inset: 0;
z-index: 200;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(4px);
.align-center {
align-items: center;
justify-content: center;
padding: 1rem;
}
.gift-settings-modal {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 14px;
width: 100%;
max-width: 500px;
max-height: 90vh;
/* =========================================
COMPOSANTS MODALE & EDITION IN-PLACE
========================================= */
/* Sélecteur d'émojis horizontal */
.emoji-picker {
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
animation: modalIn 0.18s ease;
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.96) translateY(-8px);
}
to {
opacity: 1;
transform: none;
}
}
/* ==========================================================================
MODALE PARAMÈTRES (Design System Todo)
========================================================================== */
.gift-settings-backdrop {
display: none;
position: fixed;
inset: 0;
z-index: 200;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
padding: 1rem;
}
.gift-settings-backdrop.show {
display: flex;
}
.gift-settings-modal {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 14px;
width: 100%;
max-width: 500px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
animation: giftModalIn 0.18s ease;
}
@keyframes giftModalIn {
from {
opacity: 0;
transform: scale(0.96) translateY(-8px);
}
to {
opacity: 1;
transform: none;
}
}
.gift-settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light);
}
.gift-settings-header h3 {
font-size: 1rem;
font-weight: 700;
margin: 0;
}
.gift-settings-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 1.2rem;
border-radius: 6px;
padding: 0.2rem 0.4rem;
}
.gift-settings-close:hover {
background: var(--bg-page);
}
.gift-settings-body {
padding: 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.gift-add-subtitle {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin: 0.5rem 0 0;
}
.gift-add-form {
display: flex;
gap: 0.5rem;
gap: 8px;
flex-wrap: wrap;
}
/* Aligner le bouton sur celui de Todo */
.gift-add-form .btn-primary {
background: var(--primary);
color: #fff;
.emoji-option {
cursor: pointer;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-light);
border-radius: 8px;
font-size: 1.4rem;
transition: all 0.2s;
background: transparent;
user-select: none;
}
.emoji-option:hover {
background: var(--bg-page);
border-color: #cbd5e1;
}
.emoji-option.selected {
border-color: var(--primary);
background: var(
--bg-subtle
); /* ou un bleu très clair rgba(59, 130, 246, 0.1) */
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.gift-add-form .btn-primary:hover {
background: var(--primary-dark);
/* Alignement des boutons d'ajout */
.modal-actions-right {
display: flex;
gap: 10px;
justify-content: flex-end;
width: 100%;
margin-top: 10px;
padding-top: 15px;
border-top: 1px solid var(--border-light);
}
/* Édition In-Place sur les lignes de la liste */
.inline-edit-form {
display: flex;
gap: 8px;
align-items: center;
background: var(--bg-subtle);
padding: 8px 12px;
border-radius: 8px;
width: 100%;
flex-wrap: wrap; /* Évite que ça dépasse sur mobile */
}
.inline-edit-form .pf-input {
margin: 0;
}
+15 -6
View File
@@ -90,13 +90,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
"INSERT INTO users (username, password_hash, display_name, family_id) VALUES (?, ?, ?, ?)"
)->execute([$username, $hash, $display_name, $family['id']]);
$user_id = (int)$meta_pdo->lastInsertId();
// 🔥 INJECTION ICI : Ajouter la personne à la base de la famille (Couleur orangée pour différencier)
$tenant_db = $family['db_name'];
$tenantPdo = new PDO("mysql:host=$db_host;dbname=$tenant_db;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$stmtPerson = $tenantPdo->prepare("INSERT INTO pf_people (name, user_id, role, color, is_active) VALUES (?, ?, 'parent', '#f59e0b', 1)");
$stmtPerson->execute([$display_name, $user_id]);
$_SESSION['user'] = [
'id' => (int)$meta_pdo->lastInsertId(),
'id' => $user_id,
'username' => $username,
'display_name' => $display_name,
'family_id' => (int)$family['id'],
];
$_SESSION['family_db'] = $family['db_name'];
$_SESSION['family_db'] = $tenant_db;
session_regenerate_id(true);
header('Location: /index.php');
exit;
@@ -129,6 +137,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
)->execute([$username, $hash, $display_name, $family_id]);
$user_id = (int)$meta_pdo->lastInsertId();
// 🔥 INJECTION ICI : Ajouter le créateur comme premier parent de la famille (Couleur bleue)
$tenantPdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$stmtPerson = $tenantPdo->prepare("INSERT INTO pf_people (name, user_id, role, color, is_active) VALUES (?, ?, 'parent', '#0891b2', 1)");
$stmtPerson->execute([$display_name, $user_id]);
$meta_pdo->commit();
$_SESSION['user'] = [
@@ -169,7 +182,6 @@ require __DIR__ . '/header.php';
<div class="pf-login-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<!-- Onglets -->
<div style="display:flex;gap:8px;margin-bottom:24px">
<button id="tab-create" onclick="switchTab('create')"
class="pf-btn" style="flex:1">Nouvel espace</button>
@@ -177,7 +189,6 @@ require __DIR__ . '/header.php';
class="pf-btn btn-secondary" style="flex:1">Rejoindre</button>
</div>
<!-- Formulaire commun -->
<form method="post" action="/register.php" id="reg-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" id="reg-action" value="create">
@@ -208,7 +219,6 @@ require __DIR__ . '/header.php';
placeholder="••••••" autocomplete="new-password">
</div>
<!-- Champs spécifiques : Nouvel espace -->
<div id="section-create">
<div class="pf-form-group">
<label class="pf-label">Nom de la famille / foyer</label>
@@ -218,7 +228,6 @@ require __DIR__ . '/header.php';
</div>
</div>
<!-- Champs spécifiques : Rejoindre -->
<div id="section-join" style="display:none">
<div class="pf-form-group">
<label class="pf-label">Code d'invitation</label>
+1 -1
View File
@@ -630,7 +630,7 @@ require __DIR__ . '/header.php';
</section>
<?php endif; ?>
<?php if ($_SESSION['user']['is_admin']): ?>
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
<div class="pf-settings-admin-link">
<a href="/admin/">→ Panneau d'administration</a>
</div>