29 Commits
Author SHA1 Message Date
FeFeClochette c3eae9e1d4 estimation frais
Deploy HouseHub / deploy (push) Successful in 1s
2026-09-15 19:18:38 +02:00
FeFeClochette 4cfef07dc6 Create food planning & merge liste into food
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-15 10:58:23 +02:00
FeFeClochette 3c615e3021 doublon lang
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-10 10:00:21 +02:00
FeFeClochette 094a5b2b91 Change showtoast 2026-09-10 09:45:09 +02:00
FeFeClochette dc8a2e5244 fix categ
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-09 12:49:20 +02:00
FeFeClochette 8a7b5b6644 fix css
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-03 16:15:53 +02:00
FeFeClochette a2e0904132 recap carole
Deploy HouseHub / deploy (push) Successful in 1s
2026-09-03 13:40:50 +02:00
FeFeClochette cfe8962bd4 fix migrate
Deploy HouseHub / deploy (push) Successful in 1s
2026-09-02 20:25:50 +02:00
FeFeClochette 42ccb89b66 migrate snapshot
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-02 20:23:51 +02:00
FeFeClochette 21c9b37ae0 color select
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-02 19:13:51 +02:00
FeFeClochette c05802fc79 align account
Deploy HouseHub / deploy (push) Successful in 1s
2026-09-01 16:53:26 +02:00
FeFeClochette 348a8f1811 fix transfert perso
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-01 16:31:30 +02:00
FeFeClochette 28a98f5790 fix delete category
Deploy HouseHub / deploy (push) Successful in 1s
2026-09-01 16:18:14 +02:00
FeFeClochette 32c291ff2c fix vacances
Deploy HouseHub / deploy (push) Successful in 1s
2026-08-27 16:36:24 +02:00
FeFeClochette b3139b577a remember me
Deploy HouseHub / deploy (push) Successful in 2s
2026-08-26 19:02:39 +02:00
FeFeClochette f19a7442de arrange leave type
Deploy HouseHub / deploy (push) Successful in 22s
2026-08-18 16:58:09 +02:00
FeFeClochette 536f479423 fix holidfays
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-31 13:14:50 +02:00
FeFeClochette 685c649e7c update holidays photo
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-30 18:17:03 +02:00
FeFeClochette 074d8e12d2 fix
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-30 12:50:12 +02:00
FeFeClochette f748330a1b gps expense
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-29 14:58:50 +02:00
FeFeClochette ebdbee71db planning mobil
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-28 16:37:39 +02:00
FeFeClochette 659b286967 imprv roadtrip
Deploy HouseHub / deploy (push) Successful in 3s
2026-07-28 08:51:55 +02:00
FeFeClochette ac8ebf7328 update road trip
Deploy HouseHub / deploy (push) Successful in 1s
2026-07-24 17:55:56 +02:00
FeFeClochette 49d4ec7c35 update calendar
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-22 18:36:08 +02:00
FeFeClochette c48a6ba116 pf_holidays
Deploy HouseHub / deploy (push) Successful in 4s
2026-07-20 20:39:26 +02:00
FeFeClochette 20d6c9b7b0 provisions
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-07 21:26:44 +02:00
FeFeClochette d856d0a409 Deploy from parent_id to production
Deploy HouseHub / deploy (push) Successful in 4s
2026-07-01 10:30:34 +02:00
FeFeClochette 86ef3ccbf2 Deploy from parent_id to production
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-24 21:32:27 +02:00
percoandClaude Sonnet 4.6 3e98de9c18 fix(memo): supprime loading=lazy sur les images en vue note
Deploy HouseHub / deploy (push) Successful in 2s
loading="lazy" dans un conteneur parent avec overflow:hidden empêche
le browser de détecter les images comme visibles → elles ne chargent pas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:23:38 +02:00
64 changed files with 38337 additions and 5004 deletions

No files matched your search

+7 -1
View File
@@ -59,12 +59,18 @@ require __DIR__ . '/header.php';
<span class="tab-icon">📊</span>
<span><?= tr('budget_tab_recap') ?></span>
</a>
<a href="?tab=provisions" class="tab-item <?= $tab == 'provisions' ? 'active' : '' ?>">
<span class="tab-icon">🧮</span>
<span><?= tr('budget_tab_provisions') ?></span>
</a>
</nav>
</div>
<section class="pf-section">
<?php
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev'];
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev', 'provisions'];
if (in_array($tab, $allowedTabs)) {
$viewPath = __DIR__ . "/modules/budget/views/" . $tab . ".php";
+196 -113
View File
@@ -1,127 +1,210 @@
/* ════════════════════════════════════════════════════════════════════
dark-mode.css — Dark mode overrides (all modules)
dark-mode.css — Dark mode overrides (Cleaned & Optimized)
Chargé en dernier dans le head pour garantir la cascade.
════════════════════════════════════════════════════════════════════ */
/* ─── HOME PAGE ─────────────────────────────────────────────────────────────── */
/* ─── FONDS & CONTENEURS PRINCIPAUX ────────────────────────────────── */
[data-theme="dark"] .pf-card,
[data-theme="dark"] .pf-modal-content,
[data-theme="dark"] .budget-tabs-container,
[data-theme="dark"] .fc-header-row,
[data-theme="dark"] .fc-month-nav-row,
[data-theme="dark"] .fc-month-calendar-wrapper,
[data-theme="dark"] .fc-month-balances,
[data-theme="dark"] .hol-idea-card,
[data-theme="dark"] .pf-holidays__titlebar,
[data-theme="dark"] .hol-filters-row,
[data-theme="dark"] .cl-titlebar,
[data-theme="dark"] .pf-child-section,
[data-theme="dark"] .pf-child-totals-bar,
[data-theme="dark"] .pf-gift-card-compact,
[data-theme="dark"] .epargne-grid-table,
[data-theme="dark"] .sticky-col {
background: var(--bg-panel) !important;
border-color: var(--border-light) !important;
color: var(--text-main) !important;
}
[data-theme="dark"] .owner-tabs,
[data-theme="dark"] .fc-view-controls,
[data-theme="dark"] .fc-day--other-month,
[data-theme="dark"] .fc-minimal-balance-card,
[data-theme="dark"] .pf-summary-pill,
[data-theme="dark"] .pf-filter-bar {
background: var(--bg-page) !important;
border-color: var(--border-light) !important;
color: var(--text-muted) !important;
}
/* ─── TYPOGRAPHIE GLOBALE ──────────────────────────────────────────── */
[data-theme="dark"] h1,
[data-theme="dark"] h2,
[data-theme="dark"] h3,
[data-theme="dark"] .pf-modal-title,
[data-theme="dark"] .pf-card-title,
[data-theme="dark"] .hol-main-title,
[data-theme="dark"] .pf-gift-title,
[data-theme="dark"] .budget-view h2,
[data-theme="dark"] .view-header h2,
[data-theme="dark"] .month-header-container {
color: var(--text-main) !important;
}
[data-theme="dark"] .pf-card-desc,
[data-theme="dark"] .tab-item {
color: var(--text-muted);
}
/* ─── INPUTS & SÉLECTEURS ──────────────────────────────────────────── */
[data-theme="dark"] .pf-input,
[data-theme="dark"] .pf-filter-select,
[data-theme="dark"] .hol-year-select,
[data-theme="dark"] .budget-view input,
[data-theme="dark"] .budget-view select,
[data-theme="dark"] .budget-view textarea,
[data-theme="dark"] .hol-idea-card input,
[data-theme="dark"] .hol-idea-card textarea,
[data-theme="dark"] .hol-idea-card select {
background: #1c2128 !important;
border-color: var(--border-light) !important;
color: var(--text-main) !important;
}
[data-theme="dark"] .pf-input::placeholder {
color: #484f58 !important;
}
/* ─── BOUTONS & ÉTATS ACTIFS ───────────────────────────────────────── */
[data-theme="dark"] .pf-btn.btn-secondary,
[data-theme="dark"] .fc-nav-button,
[data-theme="dark"] .fc-view-button,
[data-theme="dark"] .hol-map-toggle,
[data-theme="dark"] .cl-view-btn,
[data-theme="dark"] .pf-btn-icon-text,
[data-theme="dark"] .owner-tab {
background: var(--bg-panel) !important;
color: var(--text-muted) !important;
border-color: var(--border-light) !important;
}
[data-theme="dark"] .pf-btn.btn-secondary:hover,
[data-theme="dark"] .fc-nav-button:hover,
[data-theme="dark"] .hol-map-toggle:hover,
[data-theme="dark"] .pf-btn-icon-text:hover {
background: rgba(255, 255, 255, 0.06) !important;
color: var(--text-main) !important;
}
[data-theme="dark"] .tab-item.active,
[data-theme="dark"] .fc-view-button--active,
[data-theme="dark"] .cl-view-btn.is-active {
background: rgba(59, 130, 246, 0.15) !important;
color: var(--primary) !important;
border-color: rgba(59, 130, 246, 0.3) !important;
}
[data-theme="dark"] .owner-tab.active {
background: var(--primary) !important;
color: #fff !important;
}
/* ─── TABLES ───────────────────────────────────────────────────────── */
[data-theme="dark"] .pf-table,
[data-theme="dark"] .fc-month-table,
[data-theme="dark"] #planningTable-wrapper {
background: var(--bg-panel) !important;
color: var(--text-main);
border-color: var(--border-light) !important;
}
[data-theme="dark"] .pf-table thead th,
[data-theme="dark"] .fc-month-table th,
[data-theme="dark"] #planningTable thead th,
[data-theme="dark"] th.sticky-col,
[data-theme="dark"] .pf-child-header {
background: #1c2128 !important;
color: var(--text-muted) !important;
border-color: var(--border-light) !important;
}
[data-theme="dark"] .pf-table tbody td,
[data-theme="dark"] .fc-month-table td,
[data-theme="dark"] #planningTable tbody td {
border-color: rgba(48, 54, 61, 0.5) !important;
}
[data-theme="dark"] .pf-table tbody tr:hover td,
[data-theme="dark"] .fc-month-table td:hover:not(.fc-day--other-month) {
background: rgba(255, 255, 255, 0.04) !important;
}
[data-theme="dark"] .pf-table tfoot td {
background: #1c2128 !important;
border-color: rgba(48, 54, 61, 0.5) !important;
}
/* ─── MODULES SPÉCIFIQUES ──────────────────────────────────────────── */
/* Accueil */
[data-theme="dark"] body[data-page="home"] .pf-module-card {
background: rgba(22,27,34,.92) !important;
border: 1px solid var(--border-light) !important;
box-shadow: 0 4px 15px rgba(0,0,0,.4) !important;
background: rgba(22, 27, 34, 0.92) !important;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4) !important;
}
[data-theme="dark"] body[data-page="home"] .pf-module-card:hover {
border-color: var(--primary) !important;
}
[data-theme="dark"] body[data-page="home"] .pf-card-title { color: var(--text-main); }
[data-theme="dark"] body[data-page="home"] .pf-card-desc { color: var(--text-muted); }
[data-theme="dark"] body[data-page="home"] .pf-card-cta { color: var(--primary); }
/* ─── SHARED / GLOBAL PATTERNS ──────────────────────────────────────────────── */
[data-theme="dark"] .pf-card { background: var(--bg-panel) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .pf-table { background: var(--bg-panel); color: var(--text-main); }
[data-theme="dark"] .pf-table thead th { background: #1c2128 !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-table tbody td { background: var(--bg-panel); color: var(--text-main); border-color: rgba(48,54,61,.5); }
[data-theme="dark"] .pf-table tbody tr:hover td { background: rgba(255,255,255,.03); }
[data-theme="dark"] .pf-table tfoot,
[data-theme="dark"] .pf-table tfoot td { background: #1c2128; color: var(--text-main); border-color: rgba(48,54,61,.5); }
[data-theme="dark"] .pf-modal-content { background: var(--bg-panel) !important; border: 1px solid var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .pf-modal-title { color: var(--text-main) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-input,
[data-theme="dark"] .pf-filter-select,
[data-theme="dark"] .hol-year-select { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .pf-input::placeholder { color: #484f58 !important; }
[data-theme="dark"] .pf-btn.btn-secondary { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-btn.btn-secondary:hover { background: rgba(255,255,255,.06) !important; color: var(--text-main) !important; }
/* Épargne & Grilles */
[data-theme="dark"] .eg-header-row {
background: #111827 !important;
border-color: #30363d !important;
color: #e6edf3 !important;
}
[data-theme="dark"] .eg-row:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
[data-theme="dark"] .eg-row:hover {
background: rgba(255, 255, 255, 0.05);
}
/* ─── BUDGET MODULE ─────────────────────────────────────────────────────────── */
[data-theme="dark"] .budget-view { color: var(--text-main); }
[data-theme="dark"] .budget-view h2,
[data-theme="dark"] .view-header h2 { color: var(--text-main) !important; }
[data-theme="dark"] .budget-tabs-container { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .tab-item { background: var(--bg-panel); color: var(--text-muted); border-color: var(--border-light); }
[data-theme="dark"] .tab-item:hover { background: rgba(255,255,255,.06); color: var(--text-main); }
[data-theme="dark"] .tab-item.active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; }
[data-theme="dark"] .owner-tabs { background: var(--bg-page); border-color: var(--border-light); }
[data-theme="dark"] .owner-tab { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .owner-tab.active { background: var(--primary) !important; color: #fff !important; }
[data-theme="dark"] .month-header-container { color: var(--text-main); }
/* sticky col: uses var(--bg-panel) already but th.sticky-col uses hardcoded #f8fafc */
[data-theme="dark"] th.sticky-col { background: #1c2128 !important; color: var(--text-muted) !important; }
[data-theme="dark"] .sticky-col { background: var(--bg-panel) !important; color: var(--text-main) !important; }
[data-theme="dark"] .month-actions .btn-icon-small { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .month-actions .btn-icon-small:hover { background: rgba(255,255,255,.06) !important; }
[data-theme="dark"] .savings-table .row-extres td { background: rgba(245,158,11,.08) !important; }
[data-theme="dark"] .nens-title.theme-pol { background: rgba(30,58,138,.2) !important; color: #93c5fd !important; border-color: #3b82f6 !important; }
[data-theme="dark"] .nens-title.theme-pep { background: rgba(20,83,45,.2) !important; color: #86efac !important; border-color: #22c55e !important; }
[data-theme="dark"] .nens-table.theme-pol thead th { background: rgba(30,58,138,.15) !important; color: #93c5fd !important; }
[data-theme="dark"] .nens-table.theme-pep thead th { background: rgba(20,83,45,.15) !important; color: #86efac !important; }
/* Specific input/selects in budget */
[data-theme="dark"] .budget-view input,
[data-theme="dark"] .budget-view select,
[data-theme="dark"] .budget-view textarea { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
/* sum selected highlight */
[data-theme="dark"] .sum-selected { background: rgba(59,130,246,.1) !important; }
/* Éléments colorés spécifiques */
[data-theme="dark"] .savings-table .row-extres td,
[data-theme="dark"] .eg-extres-row {
background: rgba(245, 158, 11, 0.08) !important;
}
/* ─── FAMILY CALENDAR MODULE ────────────────────────────────────────────────── */
[data-theme="dark"] .pf-family-calendar { color: var(--text-main); }
[data-theme="dark"] .fc-header-row { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .fc-view-controls { background: var(--bg-page) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-view-button { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-view-button--active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; }
[data-theme="dark"] .fc-month-nav-row { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .fc-month-nav-row h3 { color: var(--text-main) !important; }
[data-theme="dark"] .fc-nav-button { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-nav-button:hover { background: rgba(255,255,255,.06) !important; }
/* Calendar table — lots of !important in source */
[data-theme="dark"] .fc-month-table { border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-month-table th { background: #1c2128 !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-month-table td { background: var(--bg-panel) !important; border-color: rgba(48,54,61,.5) !important; }
[data-theme="dark"] .fc-month-table td:hover:not(.fc-day--other-month) { background: rgba(255,255,255,.04) !important; }
[data-theme="dark"] .fc-day--other-month { background: var(--bg-page) !important; color: var(--text-muted) !important; }
[data-theme="dark"] .fc-month-calendar-wrapper,
[data-theme="dark"] .fc-month-balances { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-minimal-balance-card { background: var(--bg-page) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .fc-min-chip { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-burn-alert { background: rgba(239,68,68,.1) !important; color: #f87171 !important; }
/* Planning table (vue planning) */
[data-theme="dark"] #planningTable-wrapper { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] #planningTable thead th { background: #1c2128 !important; color: var(--text-muted) !important; }
[data-theme="dark"] #planningTable tbody td { border-color: rgba(48,54,61,.5) !important; }
[data-theme="dark"] #planningTable tbody td.col-sticky-mois,
[data-theme="dark"] #planningTable tbody td.col-sticky-sem { background: var(--bg-panel) !important; }
[data-theme="dark"] .col-month { background: var(--bg-panel) !important; color: var(--text-main) !important; }
[data-theme="dark"] .fc-week-nav-controls { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-btn-icon-text { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-btn-icon-text:hover { background: rgba(255,255,255,.06) !important; }
[data-theme="dark"] .nens-title.theme-pol {
background: rgba(30, 58, 138, 0.2) !important;
color: #93c5fd !important;
border-color: #3b82f6 !important;
}
[data-theme="dark"] .nens-table.theme-pol thead th {
background: rgba(30, 58, 138, 0.15) !important;
color: #93c5fd !important;
}
/* ─── HOLIDAYS MODULE ───────────────────────────────────────────────────────── */
[data-theme="dark"] .pf-holidays { color: var(--text-main); }
[data-theme="dark"] .pf-holidays__titlebar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .hol-main-title { color: var(--text-main) !important; }
[data-theme="dark"] .hol-idea-card { background: var(--bg-panel) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; box-shadow: var(--shadow) !important; }
[data-theme="dark"] .hol-idea-card:hover { border-color: var(--primary) !important; }
[data-theme="dark"] .hol-idea-card input,
[data-theme="dark"] .hol-idea-card textarea,
[data-theme="dark"] .hol-idea-card select { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .hol-filters-row { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .hol-map-toggle { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .hol-map-toggle:hover { background: rgba(255,255,255,.06) !important; }
[data-theme="dark"] .hol-add-btn { background: var(--primary) !important; color: #fff !important; }
[data-theme="dark"] .nens-title.theme-pep {
background: rgba(20, 83, 45, 0.2) !important;
color: #86efac !important;
border-color: #22c55e !important;
}
[data-theme="dark"] .nens-table.theme-pep thead th {
background: rgba(20, 83, 45, 0.15) !important;
color: #86efac !important;
}
/* ─── GIFT-LIST MODULE ──────────────────────────────────────────────────────── */
[data-theme="dark"] .pf-gift-list { color: var(--text-main); }
[data-theme="dark"] .pf-gift-list h1 { color: var(--text-main) !important; }
[data-theme="dark"] .cl-titlebar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .cl-view-btn { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .cl-view-btn.is-active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; }
[data-theme="dark"] .pf-filter-bar { background: var(--bg-page) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-filter-select { background: #1c2128 !important; color: var(--text-main) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-child-section { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-child-header { background: #1c2128 !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-child-header h3 { color: var(--text-main) !important; }
[data-theme="dark"] .pf-child-totals-bar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-summary-pill { background: var(--bg-page) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .pf-gift-card-compact { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-gift-card-compact:hover { border-color: var(--primary) !important; }
[data-theme="dark"] .pf-gift-title { color: var(--text-main) !important; }
[data-theme="dark"] .pf-gift-footer { border-color: var(--border-light) !important; }
[data-theme="dark"] .sum-selected {
background: rgba(59, 130, 246, 0.1) !important;
}
[data-theme="dark"] .fc-burn-alert {
background: rgba(239, 68, 68, 0.1) !important;
color: #f87171 !important;
}
[data-theme="dark"] .month-actions .btn-icon-small {
background: #1c2128 !important;
border-color: var(--border-light) !important;
color: var(--text-main) !important;
}
[data-theme="dark"] .month-actions .btn-icon-small:hover {
background: rgba(255, 255, 255, 0.06) !important;
}
+1
View File
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS users (
is_admin TINYINT(1) DEFAULT 0,
is_active TINYINT(1) DEFAULT 1,
lang VARCHAR(5) DEFAULT 'fr',
remember_token VARCHAR(255) DEFAULT NULL, -- 👈 NOUVEAU CHAMP ICI
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (family_id) REFERENCES families(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+35 -6
View File
@@ -88,7 +88,8 @@ CREATE TABLE IF NOT EXISTS pf_leave_snapshots (
person_id INT NOT NULL,
leave_type VARCHAR(50) NOT NULL,
snapshot_date DATE NOT NULL,
remaining_balance DECIMAL(6,2) DEFAULT 0
remaining_balance DECIMAL(6,2) DEFAULT 0.00,
carry_over_balance DECIMAL(6,2) DEFAULT 0.00
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_person_leave_meta (
@@ -97,9 +98,12 @@ CREATE TABLE IF NOT EXISTS pf_person_leave_meta (
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,
carry_over_max_days DECIMAL(5,2) DEFAULT 0.00,
carry_over_deadline_month INT(11) DEFAULT 12,
anniversary_date DATE DEFAULT NULL,
INDEX (person_id),
INDEX (leave_type)
INDEX (leave_type),
UNIQUE KEY uq_person_leave (person_id, leave_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ─── Types de Congés (Référentiel) ─────────────────────────────────────────
@@ -109,12 +113,11 @@ CREATE TABLE IF NOT EXISTS pf_leave_types (
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);
INSERT IGNORE INTO pf_leave_types (code, label, default_allowance, reset_month) VALUES
('CP', 'Congés Payés', 25.00, 6);
CREATE TABLE IF NOT EXISTS pf_calendar_weeks (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -152,6 +155,8 @@ CREATE TABLE IF NOT EXISTS pf_budget_items (
type VARCHAR(50),
payment_day INT DEFAULT NULL,
is_estimate TINYINT(1) DEFAULT 0,
is_dynamic TINYINT(1) DEFAULT 0,
dynamic_code VARCHAR(50) DEFAULT NULL,
reg_month VARCHAR(7) DEFAULT NULL,
mapping_keywords TEXT DEFAULT NULL,
holiday_id INT DEFAULT NULL,
@@ -172,6 +177,18 @@ CREATE TABLE IF NOT EXISTS pf_expenses (
salary_id INT DEFAULT NULL -- 🔥 AJOUT : Lien vers le salaire configuré
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `pf_expected_expenses` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`title` VARCHAR(150) NOT NULL,
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`expected_date` DATE NOT NULL,
`is_paid` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_expected_date` (`expected_date`),
INDEX `idx_is_paid` (`is_paid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS pf_alloc_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
@@ -297,7 +314,8 @@ CREATE TABLE IF NOT EXISTS pf_holidays (
budget_extra DECIMAL(10,2) DEFAULT 0,
notes TEXT DEFAULT NULL,
vehicle_id INT DEFAULT NULL,
return_step_id INT DEFAULT NULL, -- 🔥 NOUVEAU : Point de bascule du retour
return_step_id INT DEFAULT NULL,
image_url VARCHAR(500) DEFAULT NULL,
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -645,3 +663,14 @@ CREATE TABLE IF NOT EXISTS pf_planka_config (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;
-- ─── Planificateur de Repas ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_meals_plan (
id INT AUTO_INCREMENT PRIMARY KEY,
plan_date DATE NOT NULL,
service ENUM('lunch', 'dinner') NOT NULL,
person_id INT NOT NULL,
meal_name VARCHAR(500) DEFAULT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_meal_plan (plan_date, service, person_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+92 -25
View File
@@ -85,7 +85,7 @@ $pageCss = "/modules/family-calendar/family-calendar.css";
require __DIR__ . '/header.php';
?>
<div class="pf-container" style="max-width:100%; padding:0;">
<div class="pf-container" style="--p1-main: <?= $parents[0]['color'] ?? '#0891b2' ?>; --p2-main: <?= $parents[1]['color'] ?? '#f59e0b' ?>; max-width:100%; padding:0;">
<div class="fc-header-row" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; margin-bottom: 24px;">
<!-- Bloc Titre + Roue crantée -->
@@ -185,18 +185,33 @@ require __DIR__ . '/header.php';
<div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_remaining_balance') ?></label>
<div style="display:flex; align-items:center;">
<input type="number" step="0.5" id="snapBalance" class="pf-input" placeholder="<?= tr('fc_placeholder_balance') ?>" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--pf-primary);" required>
<!-- 🔥 STEP 0.01 POUR DEUX DÉCIMALES STRICTES -->
<input type="number" step="0.01" id="snapBalance" class="pf-input" placeholder="<?= tr('fc_placeholder_balance') ?>" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--pf-primary);" required>
<span style="background: var(--pf-bg-lighter); border: 1px solid var(--pf-border); padding: 0 12px; height: 42px; display: flex; align-items: center; border-radius: 0 8px 8px 0; color: var(--pf-text-muted); font-size: 0.9rem; font-weight: 600;">
<?= tr('fc_unit_days') ?>
</span>
</div>
</div>
<!-- NOUVEAU BLOC : Solde reporté -->
<div class="pf-form-group" style="margin-top: 15px;">
<label class="pf-label"><?= tr('fc_label_carry_over') ?></label>
<div style="display:flex; align-items:center;">
<!-- 🔥 STEP 0.01 POUR DEUX DÉCIMALES STRICTES -->
<input type="number" step="0.01" id="snapCarryOver" class="pf-input" placeholder="ex: 2.00" value="0" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--danger);" required>
<span style="background: var(--pf-bg-lighter); border: 1px solid var(--pf-border); padding: 0 12px; height: 42px; display: flex; align-items: center; border-radius: 0 8px 8px 0; color: var(--pf-text-muted); font-size: 0.9rem; font-weight: 600;">
<?= tr('fc_unit_days') ?>
</span>
</div>
<small class="pf-muted-note"><?= tr('fc_carry_over_note') ?></small>
</div>
<!-- FOOTER INTÉGRÉ DANS LE FORMULAIRE POUR ÉVITER LE BUG DE CIBLAGE -->
<div class="pf-modal-footer" style="margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--pf-border); display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" class="pf-btn pf-btn-secondary" onclick="document.getElementById('modalSnapshot').style.display = 'none'; document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button>
<button type="submit" id="btnSubmitSnapshot" class="pf-btn pf-btn-primary"><?= tr('fc_btn_save_snap') ?></button>
</div>
</form>
</div>
<div class="pf-modal-footer">
<button type="button" class="pf-btn pf-btn-secondary" onclick="document.getElementById('modalSnapshot').classList.remove('open'); document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button>
<button type="submit" form="formSnapshot" class="pf-btn pf-btn-primary"><?= tr('fc_btn_save_snap') ?></button>
</div>
</div>
</div>
@@ -289,6 +304,25 @@ require __DIR__ . '/header.php';
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 5px;">
<div style="flex: 1; min-width: 140px;">
<label class="pf-label"><?= tr('fc_carry_max') ?></label>
<input type="number" step="0.5" id="lt-carry-max" class="pf-input" placeholder="ex: 2" value="0" required>
</div>
<div style="flex: 1; min-width: 140px;">
<label class="pf-label"><?= tr('fc_carry_deadline') ?></label>
<select id="lt-carry-deadline" class="pf-input">
<option value="1"><?= tr('month_01') ?></option>
<option value="2"><?= tr('month_02') ?></option>
<option value="3"><?= tr('month_03') ?></option>
<option value="4"><?= tr('month_04') ?></option>
<option value="5"><?= tr('month_05') ?></option>
<option value="6"><?= tr('month_06') ?></option>
<option value="12"><?= tr('fc_deadline_none') ?></option>
</select>
</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>
@@ -359,24 +393,36 @@ require __DIR__ . '/header.php';
<table id="planningTable">
<thead>
<tr>
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_month') ?></th>
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_week_short') ?></th>
<th rowspan="3" class="col-month col-sticky-mois rotated-text"><span><?= tr('fc_col_month') ?></span></th>
<th rowspan="3" class="col-month col-sticky-sem rotated-text"><span><?= tr('fc_col_week') ?></span></th>
<th rowspan="3" class="col-day"><?= tr('day_mon') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_tue') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_wed') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_thu') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_fri') ?></th>
<?php foreach ($activeCareModes as $mode): ?>
<!-- 1. Congés de l'intervenant -->
<?php foreach ($helpers as $helper):
$hLeaves = $leaveMatrix[$helper['id']] ?? [];
foreach ($hLeaves as $type):
?>
<th rowspan="3" class="col-total rotated-text"><span><?= htmlspecialchars($type) ?> <?= htmlspecialchars($helper['name']) ?></span></th>
<?php endforeach; endforeach; ?>
<!-- 2. Méthodes de garde (sans Nounou si l'intervenant est configuré) -->
<?php foreach ($activeCareModes as $mode):
if (strtolower($mode) === 'nounou' && !empty($helpers)) continue;
?>
<th rowspan="3" class="col-total rotated-text"><span><?= htmlspecialchars($mode) ?></span></th>
<?php endforeach; ?>
<!-- 3. Maladie par enfant -->
<?php foreach ($kids as $kid): ?>
<th rowspan="3" class="col-total rotated-text"><span>Maladie <?= htmlspecialchars($kid['name']) ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('fc_menu_sick') ?> <?= htmlspecialchars($kid['name']) ?></span></th>
<?php endforeach; ?>
<?php foreach ($parents as $index => $parent):
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia'; // Couleurs alternées
$parentClass = ($index % 2 === 0) ? 'col-p1' : 'col-p2';
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
$colspan = count($pLeaves) * 2;
?>
@@ -387,7 +433,7 @@ require __DIR__ . '/header.php';
</tr>
<tr>
<?php foreach ($parents as $index => $parent):
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
$parentClass = ($index % 2 === 0) ? 'col-p1' : 'col-p2';
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
?>
<?php foreach ($pLeaves as $type): ?>
@@ -397,7 +443,7 @@ require __DIR__ . '/header.php';
</tr>
<tr>
<?php foreach ($parents as $index => $parent):
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
$parentClass = ($index % 2 === 0) ? 'col-p1' : 'col-p2';
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
?>
<?php foreach ($pLeaves as $type): ?>
@@ -413,6 +459,19 @@ require __DIR__ . '/header.php';
</div>
</section>
<?php if (!empty($helpers)): ?>
<section class="pf-section pf-mt-15">
<div id="recapHelperContent"></div>
<div style="display: none;">
<!-- Champs masqués pour maintenir la compatibilité avec la logique JS actuelle -->
<select id="recapHelperMonth" class="pf-input"></select>
<select id="recapHelperYear" class="pf-input"></select>
<input type="date" id="recapHelperStart" class="pf-input">
<input type="date" id="recapHelperEnd" class="pf-input">
</div>
</section>
<?php endif; ?>
<section class="pf-section pf-section--bottom-panels" style="margin-top: 1.5rem;">
<div class="pf-card">
<h2 class="pf-card-title"><?= tr('fc_legend_title') ?></h2>
@@ -422,15 +481,23 @@ require __DIR__ . '/header.php';
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-school-holiday"></div><span><?= tr('leg_school_holidays') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-public-holiday"></div><span><?= tr('leg_public_holiday') ?></span></div>
<!-- Intervenants (Dynamique) -->
<?php foreach ($helpers as $helper): ?>
<div class="pf-legend-item"><div class="pf-legend-color" style="background: var(--warning);"></div><span>Off <?= htmlspecialchars($helper['name']) ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color" style="background: var(--danger);"></div><span>Extra <?= htmlspecialchars($helper['name']) ?></span></div>
<?php endforeach; ?>
<!-- Intervenants (Dynamique selon les congés paramétrés) -->
<?php
$helperColorIndex = 0;
foreach ($helpers as $helper):
$hLeaves = $leaveMatrix[$helper['id']] ?? [];
foreach ($hLeaves as $type):
?>
<div class="pf-legend-item">
<div class="pf-legend-color fc-bg-palette-<?= $helperColorIndex % 8 ?>"></div>
<span><?= htmlspecialchars($type) ?> <?= htmlspecialchars($helper['name']) ?></span>
</div>
<?php $helperColorIndex++; endforeach; endforeach; ?>
<!-- Modes de garde (Dynamique avec icônes) -->
<!-- Modes de garde (On masque "Nounou" si l'intervenant gère lui-même ses présences) -->
<?php foreach ($activeCareModes as $index => $mode):
$modeLower = strtolower($mode);
if ($modeLower === 'nounou' && !empty($helpers)) continue;
if ($modeLower === 'avis'): ?>
<div class="pf-legend-item">
<img src="/modules/family-calendar/assets/img/avis.svg" style="width:16px;height:16px;object-fit:contain;margin-right:6px;" alt="Avis">
@@ -441,18 +508,17 @@ require __DIR__ . '/header.php';
<span style="font-size:1.1rem; line-height:1; margin-right:6px;">🏫</span>
<span><?= htmlspecialchars($mode) ?></span>
</div>
<?php else:
$hue = ($index * 137) % 360; ?>
<?php else: ?>
<div class="pf-legend-item">
<div class="pf-legend-color" style="background: hsl(<?= $hue ?>, 70%, 50%);"></div>
<div class="pf-legend-color" style="background: var(--primary);"></div>
<span><?= htmlspecialchars($mode) ?></span>
</div>
<?php endif;
endforeach; ?>
<!-- Enfants Malades (Dynamique avec couleur BDD) -->
<!-- Enfants Malades (Une puce par enfant) -->
<?php foreach ($kids as $kid):
$color = !empty($kid['color']) ? $kid['color'] : 'var(--danger)';
$color = !empty($kid['color']) ? $kid['color'] : '#e11d48';
?>
<div class="pf-legend-item">
<div class="pf-legend-color" style="background: <?= htmlspecialchars($color) ?>; opacity: 0.8;"></div>
@@ -477,5 +543,6 @@ window.FAMILY_CONFIG = {
}
};
</script>
<script src="/modules/family-calendar/family-calendar.js"></script>
<script src="/modules/family-calendar/family-calendar.js?v=<?= time() ?>"></script>
<?php require __DIR__ . '/footer.php'; ?>
+53
View File
@@ -0,0 +1,53 @@
<?php
// food.php
require __DIR__ . '/includes/auth.php';
require __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/i18n.php';
require_login('/login.php');
// Gestion de l'onglet actif
$tab = $_GET['tab'] ?? 'meals';
$pageTitle = tr('mod_food_name');
$activePage = "food";
$pageCss = "/modules/food/food.css";
require __DIR__ . '/header.php';
?>
<div class="pf-container">
<div class="pf-hero budget-hero">
<div class="budget-title-group">
<h1>🍳 <?= tr('mod_food_name') ?></h1>
</div>
<nav class="budget-tabs-container">
<a href="?tab=meals" class="tab-item <?= $tab == 'meals' ? 'active' : '' ?>">
<span class="tab-icon">🍽️</span>
<span><?= tr('food_tab_meals') ?></span>
</a>
<a href="?tab=liste" class="tab-item <?= $tab == 'liste' ? 'active' : '' ?>">
<span class="tab-icon">🛒</span>
<span><?= tr('food_tab_list') ?></span>
</a>
</nav>
</div>
<section class="pf-section">
<?php
$allowedTabs = ['meals', 'liste'];
if (in_array($tab, $allowedTabs)) {
$viewPath = __DIR__ . "/modules/food/views/{$tab}.php";
if (file_exists($viewPath)) {
require $viewPath;
} else {
echo "<p>Vue introuvable : " . htmlspecialchars($tab) . "</p>";
}
} else {
echo "<p>" . tr('error_occured') . "</p>";
}
?>
</section>
</div>
<?php require __DIR__ . '/footer.php'; ?>
+559
View File
@@ -445,6 +445,81 @@ body.pf-home .pf-mobile-close-btn {
font-size: 0.95rem;
color: var(--text-main);
}
/* --- PALETTES DE COULEURS CUSTOM --- */
.pf-color-dropdown {
position: relative;
display: inline-block;
}
.pf-color-trigger {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid var(--border-light);
cursor: pointer;
padding: 0;
transition: transform 0.2s;
}
.pf-color-trigger:hover {
transform: scale(1.1);
}
.pf-color-menu {
display: none;
position: absolute;
right: 0;
top: 100%;
margin-top: 8px;
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 12px;
box-shadow: var(--shadow-lg);
z-index: 100;
width: 180px;
gap: 8px;
flex-wrap: wrap;
}
.pf-color-menu.is-open {
display: flex;
animation: pfModalPop 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.pf-color-swatch {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
cursor: pointer;
transition: transform 0.2s;
}
.pf-color-swatch:hover {
transform: scale(1.15);
}
/* Palette Radio (Pour le formulaire d'ajout) */
.pf-radio-swatch {
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
display: inline-block;
transition:
transform 0.2s,
box-shadow 0.2s;
border: 2px solid transparent;
}
.pf-radio-swatch:hover {
transform: scale(1.1);
}
.pf-radio-swatch:has(input[type="radio"]:checked) {
transform: scale(1.1);
box-shadow:
0 0 0 2px var(--bg-panel),
0 0 0 4px var(--text-main);
}
[data-theme="dark"] .pf-radio-swatch:has(input[type="radio"]:checked) {
box-shadow:
0 0 0 2px var(--bg-panel),
0 0 0 4px var(--text-main);
}
/* Encadré pointillé (code d’invitation, etc.) */
.pf-dashed-panel {
@@ -1326,3 +1401,487 @@ details[open].pf-accordion > .pf-accordion-summary::after {
90deg
); /* 90deg rend mieux sur une roue crantée qu'un 45deg ! */
}
/* ==========================================================================
UTILITAIRES GLOBAUX (Remplacement des styles en ligne)
========================================================================== */
.pf-mt-xs {
margin-top: 0.35rem;
}
.pf-mt-md {
margin-top: 1rem;
}
.pf-mt-2 {
margin-top: 2rem;
}
.pf-mb-md {
margin-bottom: 1rem;
}
.pf-m-0 {
margin: 0;
}
.pf-py-8-16 {
padding: 8px 16px;
}
.pf-input-sm {
max-width: 120px;
}
.pf-flex-0 {
flex: 0;
}
.pf-flex-1-5 {
flex: 1.5;
}
.pf-flex-2 {
flex: 2;
}
.pf-min-w-120 {
min-width: 120px;
}
.pf-label-sm {
font-size: 0.8rem;
margin-bottom: 4px;
}
.pf-flex-gap-12-center {
display: flex;
gap: 12px;
align-items: center;
}
/* Composants Spécifiques Paramètres */
.pf-empty-dashed {
padding: 10px;
text-align: center;
border: 1px dashed var(--border-light);
border-radius: 8px;
}
.pf-border-left-dynamic {
border-left: 4px solid var(--owner-color, var(--primary));
}
.pf-attached-form {
display: flex;
gap: 8px;
background: var(--bg-soft);
padding: 12px;
border-radius: 8px;
border: 1px dashed var(--border-light);
align-items: flex-end;
flex-wrap: wrap;
}
.pf-input-color-sm {
width: 28px !important;
height: 28px !important;
}
/* --- SÉLECTEUR DE COULEUR (PALETTE) --- */
.pf-color-palette {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.pf-color-swatch {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition:
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s;
padding: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.pf-color-swatch:hover {
transform: scale(1.15);
}
.pf-color-swatch.selected {
transform: scale(1.15);
border-color: var(--bg-panel);
box-shadow: 0 0 0 3px var(--text-main); /* Un anneau extérieur pour marquer la sélection */
}
/* Variante Dark Mode */
[data-theme="dark"] .pf-color-swatch.selected {
border-color: var(--bg-panel);
box-shadow: 0 0 0 3px var(--text-main);
}
/* --- PALETTE DE COULEURS CUSTOM (HouseHub) --- */
.pf-palette-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pf-palette-sm {
gap: 6px;
}
.pf-color-swatch {
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
display: inline-block;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s,
box-shadow 0.2s;
}
.pf-color-swatch:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
}
.pf-color-swatch-sm {
width: 24px;
height: 24px;
}
.pf-color-swatch input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.pf-color-swatch:has(input[type="radio"]:checked) {
box-shadow:
0 0 0 2px var(--bg-panel),
0 0 0 4px var(--primary);
transform: scale(1.1);
}
[data-theme="dark"] .pf-color-swatch:has(input[type="radio"]:checked) {
box-shadow:
0 0 0 2px var(--bg-panel),
0 0 0 4px var(--primary);
}
/* --- PROFILS RATTACHÉS (Settings) --- */
.pf-attached-card {
display: flex;
flex-direction: column;
gap: 12px;
align-items: stretch;
padding: 14px;
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.pf-attached-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pf-attached-name {
font-size: 1.1rem;
font-weight: 700;
}
.pf-attached-color-form {
display: flex;
align-items: center;
gap: 10px;
background: var(--bg-page);
padding: 8px 12px;
border-radius: 8px;
}
.pf-attached-add-form {
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg-soft);
padding: 16px;
border-radius: 8px;
border: 1px dashed var(--border-light);
}
.pf-attached-add-row {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
}
.pf-attached-add-col {
margin-bottom: 0;
flex: 1;
min-width: 150px;
}
.pf-attached-btn-add {
align-self: flex-end;
margin-top: 4px;
}
/* --- UTILS --- */
.pf-mt-lg {
margin-top: 2rem;
}
.pf-mb-lg {
margin-bottom: 1.5rem;
}
.pf-mb-md {
margin-bottom: 1rem;
}
.pf-m-0 {
margin: 0;
}
.pf-label-sm {
font-size: 0.8rem;
margin-bottom: 4px;
}
.pf-empty-box {
padding: 10px;
text-align: center;
border: 1px dashed var(--border-light);
border-radius: 8px;
}
/* Couleurs Prédéfinies */
.pf-color-red {
background-color: #ef4444;
}
.pf-color-orange {
background-color: #f97316;
}
.pf-color-amber {
background-color: #f59e0b;
}
.pf-color-lime {
background-color: #84cc16;
}
.pf-color-green {
background-color: #22c55e;
}
.pf-color-teal {
background-color: #14b8a6;
}
.pf-color-cyan {
background-color: #06b6d4;
}
.pf-color-sky {
background-color: #0ea5e9;
}
.pf-color-blue {
background-color: #3b82f6;
}
.pf-color-indigo {
background-color: #6366f1;
}
.pf-color-violet {
background-color: #8b5cf6;
}
.pf-color-fuchsia {
background-color: #d946ef;
}
.pf-color-rose {
background-color: #f43f5e;
}
.pf-color-slate {
background-color: #64748b;
}
/* Popover Palette (Profils rattachés) */
.pf-palette-dropdown {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.pf-palette-popover {
display: none;
position: absolute;
right: 0;
top: 120%;
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 12px;
box-shadow: var(--shadow-lg);
width: 200px;
gap: 8px;
flex-wrap: wrap;
z-index: 100;
}
.pf-palette-dropdown.is-open .pf-palette-popover {
display: flex;
animation: pfModalPop 0.2s ease-out;
}
.pf-profile-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.pf-profile-actions {
display: flex;
align-items: center;
gap: 12px;
}
.pf-m-0 {
margin: 0;
}
.pf-empty-dashed {
padding: 10px;
text-align: center;
border: 1px dashed var(--border-light);
border-radius: 8px;
color: var(--text-muted);
}
/* --- UTILITAIRES & PROFILS RATTACHÉS (SETTINGS) --- */
.pf-mt-lg {
margin-top: 2rem;
}
.pf-mb-md {
margin-bottom: 1rem;
}
.pf-mb-lg {
margin-bottom: 1.5rem;
}
.pf-mb-0 {
margin-bottom: 0;
}
.pf-m-0 {
margin: 0;
}
.pf-profile-color-note {
display: block;
margin-top: 8px;
}
.pf-attached-card {
border-left: 4px solid var(--owner-color, #3b82f6);
}
.pf-attached-actions {
display: flex;
align-items: center;
gap: 12px;
}
.pf-color-picker-sm {
width: 24px !important;
height: 24px !important;
min-width: 24px !important;
}
.pf-btn-delete-sm {
font-size: 1.2rem;
padding: 4px;
}
.pf-empty-dashed {
padding: 10px;
text-align: center;
border: 1px dashed var(--border-light);
border-radius: 8px;
}
.pf-attached-form {
display: flex;
gap: 8px;
background: var(--bg-soft);
padding: 12px;
border-radius: 8px;
border: 1px dashed var(--border-light);
align-items: flex-end;
flex-wrap: wrap;
}
.pf-flex-2 {
flex: 2;
min-width: 120px;
}
.pf-flex-1-5 {
flex: 1.5;
min-width: 120px;
}
.pf-col-center {
display: flex;
flex-direction: column;
align-items: center;
}
.pf-label-sm {
font-size: 0.8rem;
margin-bottom: 4px;
}
.pf-btn-pad-sm {
padding: 8px 16px;
}
/* === COMPOSANT : NAVIGATION TABS (Pills) === */
.budget-tabs-container {
display: inline-flex;
background: var(--bg-panel);
padding: 6px;
border-radius: 50px;
box-shadow: var(--shadow);
gap: 8px;
position: relative;
z-index: 10;
}
.tab-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
border-radius: 40px;
text-decoration: none;
color: var(--text-muted);
font-weight: 600;
font-size: 0.95rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
}
.tab-item:hover {
background-color: var(--bg-page);
color: var(--text-main);
}
.tab-item.active {
background: var(--primary);
color: white;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
transform: scale(1.02);
}
.tab-icon {
font-size: 1.1em;
line-height: 1;
}
@media (max-width: 768px) {
.budget-tabs-container {
display: flex !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
justify-content: flex-start !important;
-webkit-overflow-scrolling: touch;
width: 100%;
padding-bottom: 5px;
scrollbar-width: none;
}
.budget-tabs-container::-webkit-scrollbar {
display: none;
}
.tab-item {
flex: 0 0 auto !important;
padding: 8px 16px !important;
}
}
/* === COMPOSANT : EN-TÊTE DE MODULE CENTRÉ === */
.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;
}
+2 -2
View File
@@ -55,7 +55,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('liste', $mods)): ?><a href="/liste.php" class="pf-nav-link <?= $activePage === 'liste' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_liste') ?></a><?php endif; ?>
<?php if (in_array('food', $mods)): ?><a href="/food.php" class="pf-nav-link <?= $activePage === 'food' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_food') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-nav-link <?= $activePage === 'printvault' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_printvault') ?></a><?php endif; ?>
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-nav-link <?= $activePage === 'planka' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_planka') ?></a><?php endif; ?>
@@ -106,7 +106,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('liste',$mods)): ?><a href="/liste.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_liste') ?></a><?php endif; ?>
<?php if (in_array('food', $mods)): ?><a href="/food.php" class="pf-mobile-nav-link">🍳 <?= tr('menu_food') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-mobile-nav-link">🖨️ <?= tr('menu_printvault') ?></a><?php endif; ?>
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-mobile-nav-link">📋 <?= tr('menu_planka') ?></a><?php endif; ?>
+13 -2
View File
@@ -34,7 +34,18 @@ if ($tab === 'holiday_detail' && isset($_GET['id'])) {
require __DIR__ . '/modules/holidays/views/list.php';
}
// 4. Inclusion du JS global du module (Pont i18n déjà géré dans le header)
echo '<script src="/modules/holidays/holidays.js"></script>';
// 4. Inclusion des librairies globales du module (Flatpickr)
?>
<!-- Flatpickr (Vanilla JS Date Range) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css" id="flatpickr-dark-theme" disabled>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/fr.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/cat.js"></script>
<?php
$jsPath = __DIR__ . '/modules/holidays/holidays.js';
$jsVersion = file_exists($jsPath) ? filemtime($jsPath) : time();
echo '<script src="/modules/holidays/holidays.js?v=' . $jsVersion . '"></script>';
require __DIR__ . '/footer.php';
+47
View File
@@ -4,6 +4,12 @@ if (session_status() === PHP_SESSION_NONE) {
session_start();
}
/**
* Vérifie qu'un utilisateur est connecté.
* Si non, le redirige vers la page de login avec ?redirect=URL_DEMANDEE
*
* @param string|null $loginPage URL de la page de login (par défaut /login.php)
*/
/**
* Vérifie qu'un utilisateur est connecté.
* Si non, le redirige vers la page de login avec ?redirect=URL_DEMANDEE
@@ -16,6 +22,47 @@ function require_login(?string $loginPage = '/login.php'): void
session_start();
}
// --- NOUVEAU : Auto-login via Cookie ---
if (empty($_SESSION['user']) && isset($_COOKIE['hh_remember'])) {
$cookieParts = explode(':', $_COOKIE['hh_remember']);
if (count($cookieParts) === 2) {
$cookieUserId = (int)$cookieParts[0];
$cookieToken = $cookieParts[1];
global $meta_pdo;
require_once __DIR__ . '/meta_db.php';
$stmt = $meta_pdo->prepare("
SELECT u.id, u.username, u.password_hash, u.display_name, u.family_id, u.is_admin, u.is_active, u.lang, u.remember_token,
f.db_name, f.is_active as family_active, f.enabled_modules
FROM users u
LEFT JOIN families f ON f.id = u.family_id
WHERE u.id = ?
");
$stmt->execute([$cookieUserId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
// On vérifie que le token correspond au hash stocké
if ($user && $user['remember_token'] && hash_equals($user['remember_token'], hash('sha256', $cookieToken))) {
if ($user['is_active'] && (!$user['family_id'] || $user['family_active'])) {
// Connexion silencieuse réussie, on peuple la session
$_SESSION['user'] = [
'id' => (int)$user['id'],
'username' => $user['username'],
'display_name' => $user['display_name'],
'family_id' => (int)$user['family_id'],
'is_admin' => (bool)$user['is_admin'],
];
$_SESSION['family_db'] = $user['db_name'];
$_SESSION['app_lang'] = $user['lang'] ?? 'fr';
$_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts","calendar_ios"]', true);
}
}
}
}
// ---------------------------------------
if (empty($_SESSION['user'])) {
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false)) {
header('Content-Type: application/json');
+45 -38
View File
@@ -1,52 +1,59 @@
<?php
// includes/db.php
if (file_exists(__DIR__ . '/config.php')) {
require_once __DIR__ . '/config.php';
}
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$host = getenv('DB_HOST') ?: 'househub-db';
$user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme';
$db = $_SESSION['family_db'] ?? null;
$envHost = getenv('DB_HOST');
if (!$db) {
$current = basename($_SERVER['PHP_SELF'] ?? '');
if (!in_array($current, ['login.php', 'register.php'])) {
header('Location: /login.php');
exit;
}
return;
// 1. Détection stricte de l'environnement
if ($envHost === 'househub-db') {
// Docker
$host = 'househub-db';
$user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme';
} else {
// XAMPP
$host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
$user = 'root';
$pass = '';
}
// 2. Multi-tenant strict : cibler la DB de la famille courante
$db = 'househub_meta'; // Fallback par défaut
if (!empty($_SESSION['user']['family_id'])) {
$db = 'househub_f' . (int)$_SESSION['user']['family_id'];
}
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30,
];
try {
$pdo = new PDO(
"mysql:host=$host;dbname=$db;charset=utf8mb4",
$user, $pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30,
]
);
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
$pdo = new PDO($dsn, $user, $pass, $options);
try {
$foyer = $pdo->query("SELECT currency, zone_scolaire FROM pf_foyer_settings WHERE id = 1")->fetch();
if (!defined('CURRENCY')) {
define('CURRENCY', $foyer['currency'] ?? '€');
}
if (!defined('ZONE_SCOLAIRE')) {
define('ZONE_SCOLAIRE', $foyer['zone_scolaire'] ?? 'C');
}
} catch (\PDOException $e) {
if (!defined('CURRENCY')) define('CURRENCY', '€');
if (!defined('ZONE_SCOLAIRE')) define('ZONE_SCOLAIRE', 'C');
}
// ------------------------------------------------
// Forcer la collation
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
$pdo->exec("SET collation_database = utf8mb4_general_ci");
$pdo->exec("SET collation_server = utf8mb4_general_ci");
} catch (\PDOException $e) {
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
die(json_encode(['ok' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]));
die("Erreur de connexion BDD (Host: $host, DB: $db) : " . $e->getMessage());
}
?>
+426 -362
View File
File diff suppressed because it is too large. Load diff
+464 -382
View File
File diff suppressed because it is too large. Load diff
+500 -436
View File
File diff suppressed because it is too large. Load diff
+20 -4
View File
@@ -1,9 +1,23 @@
<?php
// includes/meta_db.php
// Connexion à la base meta (gestion des familles et utilisateurs)
$meta_host = getenv('DB_HOST') ?: 'househub-db';
$meta_db = 'househub_meta';
$meta_user = getenv('DB_USER') ?: 'househub';
$meta_pass = getenv('DB_PASS') ?: 'changeme';
$envHost = getenv('DB_HOST');
// Détection stricte : Docker injecte toujours 'househub-db' via le docker-compose
if ($envHost === 'househub-db') {
// Environnement Docker (Ton collègue ou la Prod)
$meta_host = 'househub-db';
$meta_user = getenv('DB_USER') ?: 'househub';
$meta_pass = getenv('DB_PASS') ?: 'changeme';
} else {
// Environnement Local XAMPP (Toi)
$meta_host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
$meta_user = 'root';
$meta_pass = '';
}
$meta_db = 'househub_meta';
try {
$meta_pdo = new PDO(
@@ -14,8 +28,10 @@ try {
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
]
);
} catch (\PDOException $e) {
die(json_encode(['error' => 'Meta DB unavailable: ' . $e->getMessage()]));
}
?>
+10 -9
View File
@@ -43,7 +43,7 @@ if ($_has_custom_bg): ?>
<section class="pf-section">
<h2 style="color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.6);"><?= tr('home_modules_title') ?></h2>
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','garage','calendar_ios']; ?>
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','food','garage','calendar_ios']; ?>
<div class="pf-modules-grid">
<?php if (in_array('calendar', $mods)): ?>
@@ -91,6 +91,15 @@ if ($_has_custom_bg): ?>
</a>
<?php endif; ?>
<?php if (in_array('food', $mods)): ?>
<a href="/food.php" class="pf-module-card">
<div class="pf-card-icon">🍳</div>
<h3 class="pf-card-title"><?= tr('mod_food_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_food_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_food') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('memo', $mods)): ?>
<a href="/memo.php" class="pf-module-card">
<div class="pf-card-icon">📝</div>
@@ -109,14 +118,6 @@ if ($_has_custom_bg): ?>
</a>
<?php endif; ?>
<?php if (in_array('liste', $mods)): ?>
<a href="/liste.php" class="pf-module-card">
<div class="pf-card-icon">🛒</div>
<h3 class="pf-card-title"><?= tr('mod_liste_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_liste_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_cart') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?>
<a href="/calendar-ios.php" class="pf-module-card">
-62
View File
@@ -1,62 +0,0 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = tr('mod_liste_name') . ' — HouseHub';
$activePage = 'liste';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/liste/assets/liste.css">
<main class="liste-main">
<!-- Tabs des listes -->
<div class="liste-tabs-bar">
<div class="liste-tabs" id="liste-tabs"></div>
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
</div>
<!-- Corps de la liste active -->
<div class="liste-body" id="liste-body">
<div class="liste-add-card">
<input type="text" id="liste-input" maxlength="500"
placeholder="<?= htmlspecialchars(tr('liste_placeholder_add')) ?>" autocomplete="off">
<button id="btn-liste-add"><?= htmlspecialchars(tr('liste_btn_add')) ?></button>
</div>
<div class="liste-toolbar" id="liste-toolbar">
<button class="btn-tool" id="btn-uncheck-all"><?= htmlspecialchars(tr('liste_btn_uncheck_all')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-delete-picked"><?= htmlspecialchars(tr('liste_btn_delete_picked')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-clear-all"><?= htmlspecialchars(tr('liste_btn_clear_all')) ?></button>
</div>
<div id="liste-items-root"></div>
<div id="liste-history-root"></div>
</div>
</main>
<script>
const LISTE_TRANSLATIONS = {
already_in: <?= json_encode(tr('liste_already_in')) ?>,
confirm_clear: <?= json_encode(tr('liste_confirm_clear')) ?>,
confirm_delete_list: <?= json_encode(tr('liste_confirm_delete_list')) ?>,
rename_list: <?= json_encode(tr('liste_rename_list')) ?>,
new_name: <?= json_encode(tr('liste_new_name')) ?>,
list_deleted: <?= json_encode(tr('liste_list_deleted')) ?>,
list_created: <?= json_encode(tr('liste_list_created')) ?>,
list_renamed: <?= json_encode(tr('liste_list_renamed')) ?>,
edit_placeholder: <?= json_encode(tr('liste_edit_placeholder')) ?>,
history_title: <?= json_encode(tr('liste_history_title')) ?>,
added: <?= json_encode(tr('liste_added')) ?>,
updated: <?= json_encode(tr('liste_updated')) ?>,
deleted: <?= json_encode(tr('liste_deleted')) ?>,
cancel: <?= json_encode(tr('cancel')) ?>,
};
</script>
<script src="/modules/liste/assets/liste.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+19
View File
@@ -51,6 +51,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_SESSION['family_db'] = $user['db_name'];
$_SESSION['app_lang'] = $user['lang'] ?? 'fr';
$_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts","calendar_ios"]', true);
// --- NOUVEAU : Logique "Se souvenir de moi" ---
if (isset($_POST['remember_me']) && $_POST['remember_me'] === '1') {
$token = bin2hex(random_bytes(32)); // Token aléatoire sécurisé
$hashedToken = hash('sha256', $token); // On stocke le hash en BDD
$meta_pdo->prepare("UPDATE users SET remember_token = ? WHERE id = ?")->execute([$hashedToken, $user['id']]);
// Création du cookie sécurisé (Expire dans 30 jours, HTTPOnly)
$cookieValue = $user['id'] . ':' . $token;
setcookie('hh_remember', $cookieValue, time() + (86400 * 30), '/', '', isset($_SERVER['HTTPS']), true);
}
// ----------------------------------------------
$redirectTo = $_GET['redirect'] ?? '/index.php';
header('Location: ' . $redirectTo);
exit;
@@ -95,6 +109,11 @@ require __DIR__ . '/header.php';
required placeholder="••••••" autocomplete="current-password">
</div>
<div class="pf-form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="remember_me" name="remember_me" value="1" class="pf-checkbox-lg">
<label class="pf-label" for="remember_me" style="margin: 0; cursor: pointer;"><?= tr('login_remember_me') ?? 'Se souvenir de moi' ?></label>
</div>
<button type="submit" class="pf-btn pf-btn-block">
<?= tr('btn_login_submit') ?>
</button>
+15 -1
View File
@@ -1,6 +1,20 @@
<?php
session_start();
// --- NOUVEAU : Révocation du "Se souvenir de moi" ---
if (isset($_SESSION['user']['id'])) {
require_once __DIR__ . '/includes/meta_db.php';
// On invalide le token en base de données
$stmt = $meta_pdo->prepare("UPDATE users SET remember_token = NULL WHERE id = ?");
$stmt->execute([$_SESSION['user']['id']]);
}
// On détruit le cookie côté navigateur
if (isset($_COOKIE['hh_remember'])) {
setcookie('hh_remember', '', time() - 3600, '/', '', isset($_SERVER['HTTPS']), true);
}
// ----------------------------------------------------
// On vide toutes les variables de session
$_SESSION = [];
@@ -21,6 +35,6 @@ if (ini_get("session.use_cookies")) {
);
}
// On renvoie vers la page de login (ou index si tu préfères)
// On renvoie vers la page de login
header('Location: /login.php');
exit;
+71 -39
View File
@@ -1,49 +1,81 @@
<?php
require_once __DIR__ . '/includes/meta_db.php';
// migrate.php
require __DIR__ . '/includes/auth.php';
require __DIR__ . '/includes/meta_db.php';
require_login();
$db_host = getenv('DB_HOST') ?: '127.0.0.1';
$db_user = getenv('DB_USER') ?: 'househub';
$db_pass = getenv('DB_PASS') ?: 'househub_dev';
echo "<h1>🗺️ Migration : Refonte Modèle Voyages</h1><ul>";
if (empty($_SESSION['user']['is_admin'])) {
die("Accès refusé. Droits administrateur requis.");
}
try {
$stmt = $meta_pdo->query("SELECT db_name, name FROM families WHERE is_active = 1");
$stmt = $meta_pdo->query("SELECT db_name FROM families");
$families = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($families as $family) {
$dbName = $family['db_name'];
try {
$pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
foreach ($families as $fam) {
$dbName = $fam['db_name'];
echo "Migration de la base : $dbName ... <br>";
// 1. Mise à jour pf_holidays
$pdo->exec("ALTER TABLE pf_holidays ADD COLUMN return_step_id INT DEFAULT NULL");
// 2. Mise à jour pf_holidays_items
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN step_type VARCHAR(20) DEFAULT 'stop'");
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN expense_context VARCHAR(20) DEFAULT NULL");
// 3. Migration des données (is_return -> return_step_id)
// On cherche la première étape cochée "is_return" et on l'assigne au voyage
$pdo->exec("
UPDATE pf_holidays h
SET return_step_id = (
SELECT id FROM pf_holidays_items i
WHERE i.holiday_id = h.id AND i.is_return = 1 AND i.location_name IS NOT NULL
LIMIT 1
)
");
// 4. Nettoyage de l'ancienne colonne
$pdo->exec("ALTER TABLE pf_holidays_items DROP COLUMN is_return");
echo "<li>✅ {$family['name']} : Schéma Voyages mis à jour !</li>";
} catch (\PDOException $e) {
echo "<li>⚠️ {$family['name']} : " . $e->getMessage() . "</li>";
// --- DÉBUT DU BLOC MODIFIÉ ---
$envHost = getenv('DB_HOST');
if ($envHost === 'househub-db') {
$host = 'househub-db';
$user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme';
} else {
$host = '127.0.0.1';
$user = 'root';
$pass = '';
}
$familyPdo = new PDO(
"mysql:host=$host;dbname=$dbName;charset=utf8mb4",
$user,
$pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// --- FIN DU BLOC MODIFIÉ ---
$sql = "
CREATE TABLE IF NOT EXISTS pf_meals_plan (
id INT AUTO_INCREMENT PRIMARY KEY,
plan_date DATE NOT NULL,
service ENUM('lunch', 'dinner') NOT NULL,
person_id INT NOT NULL,
meal_name VARCHAR(500) DEFAULT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_meal_plan (plan_date, service, person_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
";
$familyPdo->exec($sql);
echo "✅ Table pf_meals_plan créée pour $dbName.<br>";
}
echo "<br>🎉 Migration terminée avec succès.";
// À ajouter dans migrate.php
echo "Migration : Ajout des colonnes dynamiques au Budget...\n";
$stmtFam = $meta_pdo->query("SELECT id, db_name FROM families");
$families = $stmtFam->fetchAll(PDO::FETCH_ASSOC);
foreach ($families as $fam) {
$dbName = $fam['db_name'];
try {
$famPdo = new PDO("mysql:host=$meta_host;dbname=$dbName;charset=utf8mb4", $meta_user, $meta_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
// Vérification si la colonne n'existe pas déjà
$checkCol = $famPdo->query("SHOW COLUMNS FROM pf_budget_items LIKE 'is_dynamic'");
if ($checkCol->rowCount() == 0) {
$famPdo->exec("ALTER TABLE pf_budget_items
ADD COLUMN is_dynamic TINYINT(1) DEFAULT 0 AFTER is_estimate,
ADD COLUMN dynamic_code VARCHAR(50) DEFAULT NULL AFTER is_dynamic");
echo "Famille {$fam['id']} ($dbName) : Colonnes is_dynamic et dynamic_code ajoutées.\n";
}
} catch (\PDOException $e) {
echo "Erreur sur la famille {$fam['id']} ($dbName) : " . $e->getMessage() . "\n";
}
echo "</ul><h2>🎉 Migration terminée !</h2>";
} catch (Exception $e) {
die("Erreur fatale : " . $e->getMessage());
}
?>
} catch (Exception $e) {
die("❌ Erreur lors de la migration : " . $e->getMessage());
}
-49
View File
@@ -1,49 +0,0 @@
<?php
// migrate_attachments.php
// 1. Inclusion de ta connexion BDD principale (Ajuste le chemin si besoin)
require __DIR__ . '/includes/db.php';
echo "<pre>🚀 Lancement de la migration multi-bases...\n\n";
try {
// 2. Récupérer toutes les bases de données qui commencent par "househub_f"
$stmt = $pdo->query("SHOW DATABASES LIKE 'househub_f%'");
$databases = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($databases)) {
die("❌ Aucune base de données familiale trouvée (Format househub_f1, househub_f2...).\n");
}
$successCount = 0;
foreach ($databases as $dbName) {
// Sécurité : on vérifie le format exact (househub_f suivi de chiffres)
if (preg_match('/^househub_f\d+$/', $dbName)) {
echo "⚙️ Mise à jour de la base : <strong>$dbName</strong>... ";
// On bascule la connexion sur la base de la famille
$pdo->exec("USE `$dbName`");
// 3. Création de la table
$sql = "CREATE TABLE IF NOT EXISTS pf_holidays_attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
holiday_id INT NOT NULL,
item_id INT DEFAULT NULL,
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(255) NOT NULL,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_holiday_attachment FOREIGN KEY (holiday_id) REFERENCES pf_holidays(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$pdo->exec($sql);
echo "✅ OK\n";
$successCount++;
}
}
echo "\n🎉 Migration terminée avec succès sur $successCount base(s) familiale(s) !</pre>";
} catch (PDOException $e) {
die("\n❌ Erreur SQL pendant la migration : " . $e->getMessage() . "</pre>");
}
-64
View File
@@ -1,64 +0,0 @@
<?php
// migrate_categories_order.php
// ============================================================================
// CONFIGURATION DE CONNEXION (À ADAPTER)
// ============================================================================
require __DIR__ . '/includes/db.php';
echo "<h2>🚀 Migration : Ajout de l'ordre des catégories</h2>";
try {
// Connexion au serveur MySQL (sans spécifier de base de données particulière)
$pdo = new PDO("mysql:host=$host;charset=utf8mb4", $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 1. Trouver toutes les bases de données de la famille HouseHub
$stmt = $pdo->query("SHOW DATABASES LIKE 'househub_f%'");
$databases = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($databases)) {
echo "<p style='color: #ef4444;'>❌ Aucune base de données correspondant à 'househub_f*' n'a été trouvée.</p>";
exit;
}
echo "<p>🔍 <strong>" . count($databases) . "</strong> base(s) de données trouvée(s).</p>";
// 2. Boucler sur chaque base de données
foreach ($databases as $db) {
echo "<hr><div style='padding: 10px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 10px;'>";
echo "<h4 style='margin-top: 0;'>📂 Traitement de la base : <strong>$db</strong></h4>";
// Sélectionner la base de données active
$pdo->exec("USE `$db`");
// Vérifier si la table pf_budget_categories existe dans cette DB
$tableExists = $pdo->query("SHOW TABLES LIKE 'pf_budget_categories'")->rowCount() > 0;
if ($tableExists) {
// Vérifier si la colonne sort_order existe déjà (sécurité)
$columnExists = $pdo->query("SHOW COLUMNS FROM pf_budget_categories LIKE 'sort_order'")->rowCount() > 0;
if (!$columnExists) {
// La colonne n'existe pas, on l'ajoute !
$pdo->exec("ALTER TABLE pf_budget_categories ADD sort_order INT DEFAULT 0");
echo "<span style='color: #10b981;'>✅ Colonne 'sort_order' ajoutée avec succès !</span>";
} else {
// La colonne est déjà là
echo "<span style='color: #f59e0b;'>ℹ️ La colonne 'sort_order' existe déjà (Ignoré).</span>";
}
} else {
echo "<span style='color: #ef4444;'>⚠️ La table 'pf_budget_categories' n'existe pas ici.</span>";
}
echo "</div>";
}
echo "<h3>🎉 Migration terminée avec succès !</h3>";
echo "<p style='color: #64748b;'><em>Tu peux maintenant supprimer ce fichier par mesure de sécurité.</em></p>";
} catch (PDOException $e) {
echo "<h3 style='color: #ef4444;'>❌ Erreur fatale MySQL :</h3>";
echo "<pre style='background: #fee2e2; padding: 15px; border-radius: 8px;'>" . $e->getMessage() . "</pre>";
}
?>
+626
View File
@@ -0,0 +1,626 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>HouseHub - Planning Repas</title>
<style>
/* --- VARIABLES --- */
:root {
--primary: #3b82f6;
--bg-page: #f8fafc;
--bg-panel: #ffffff;
--bg-subtle: #f1f5f9;
--text-main: #0f172a;
--text-muted: #64748b;
--border-light: #e2e8f0;
--border-strong: #cbd5e1;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-lg: 0 -4px 20px rgba(0, 0, 0, 0.15);
--alert-success-bg: #dcfce7;
}
body {
font-family:
"Inter",
-apple-system,
sans-serif;
background: var(--bg-page);
color: var(--text-main);
margin: 0;
padding: 10px;
overscroll-behavior-y: none;
padding-bottom: 70px; /* Espace pour la toolbar fixe */
}
/* --- EN-TÊTE --- */
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.header-bar h1 {
margin: 0;
font-size: 1.3rem;
color: var(--text-main);
display: flex;
align-items: center;
gap: 8px;
}
.pf-btn {
background: var(--bg-panel);
color: var(--text-main);
border: 1px solid var(--border-strong);
padding: 8px 12px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
box-shadow: var(--shadow-sm);
}
.pf-btn.primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* --- MATRICE ULTRA-COMPACTE --- */
.meals-wrapper {
background: var(--bg-panel);
border-radius: 8px;
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.meals-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
}
.meals-table th,
.meals-table td {
border: 1px solid var(--border-light);
padding: 0;
text-align: center;
vertical-align: middle;
}
.meals-table th {
background: var(--bg-subtle);
font-size: 0.6rem;
font-weight: 800;
color: var(--text-muted);
padding: 6px 2px;
text-transform: uppercase;
}
.col-sticky {
width: 40px;
background: var(--bg-subtle);
border-right: 2px solid var(--border-strong) !important;
padding: 4px 2px !important;
}
.day-label {
font-size: 0.65rem;
font-weight: 800;
color: var(--text-main);
text-transform: uppercase;
display: block;
margin-bottom: 2px;
}
.day-label.invisible {
visibility: hidden;
height: 0;
margin: 0;
}
.service-icon {
background: white;
border: 1px solid var(--border-strong);
border-radius: 6px;
font-size: 1rem;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: transform 0.1s;
}
.service-icon:active {
transform: scale(0.9);
}
.row-lunch td {
border-top: 2px solid var(--border-strong) !important;
}
.row-dinner td {
border-top: 1px dashed var(--border-light);
}
.meal-input-cell {
position: relative;
height: 100%;
background: var(--bg-panel);
}
.meal-input {
width: 100%;
height: 44px;
border: none;
background: transparent;
text-align: center;
font-size: 0.75rem;
padding: 4px;
color: var(--text-main);
font-family: inherit;
resize: none;
box-sizing: border-box;
outline: none;
}
.meal-input:focus {
background: #eff6ff;
}
/* --- BARRE CONTEXTUELLE (FIXE EN BAS) --- */
.context-toolbar {
position: fixed;
bottom: -80px;
left: 0;
right: 0;
height: 60px;
background: var(--bg-panel);
border-top: 1px solid var(--border-strong);
box-shadow: var(--shadow-lg);
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
transition: bottom 0.2s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 1000;
padding: 0 10px;
}
.context-toolbar.active {
bottom: 0;
}
.ctx-btn {
background: var(--bg-subtle);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 10px 16px;
font-size: 0.85rem;
font-weight: 700;
color: var(--text-main);
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.ctx-btn.primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* --- MODALE : COPIER VERS (DESIGN GRILLE) --- */
.pf-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
align-items: center;
justify-content: center;
z-index: 2000;
}
.pf-modal.open {
display: flex;
animation: fadeInBg 0.2s;
}
.pf-modal-content {
background: var(--bg-panel);
width: 95%;
max-width: 600px;
border-radius: 12px;
padding: 20px;
box-sizing: border-box;
box-shadow: var(--shadow-lg);
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.modal-title {
margin: 0;
font-size: 1.1rem;
color: var(--text-main);
font-weight: 800;
display: flex;
align-items: center;
gap: 8px;
}
.modal-close {
background: none;
border: none;
font-size: 1.8rem;
cursor: pointer;
color: var(--text-muted);
line-height: 1;
margin-top: -5px;
}
.modal-source {
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 600;
margin: 0 0 20px 0;
}
/* Grille des Jours */
.week-section-title {
background: var(--bg-subtle);
text-align: center;
padding: 6px;
font-weight: 700;
font-size: 0.85rem;
color: var(--text-muted);
border-radius: 4px;
margin: 15px 0 10px 0;
text-transform: uppercase;
border: 1px solid var(--border-light);
}
.days-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.day-card {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 8px;
padding: 8px 4px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
box-shadow: var(--shadow-sm);
}
.day-card-title {
font-size: 0.8rem;
font-weight: 700;
color: var(--text-main);
}
.day-card-actions {
display: flex;
gap: 6px;
justify-content: center;
width: 100%;
}
.day-action-btn {
background: var(--bg-page);
border: 1px solid var(--border-strong);
border-radius: 6px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
cursor: pointer;
transition: 0.1s;
}
.day-action-btn:active {
transform: scale(0.9);
background: #e2e8f0;
}
@media (max-width: 380px) {
.days-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@keyframes fadeInBg {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
</head>
<body>
<div class="header-bar">
<h1>
<span style="opacity: 0.5">🍽️</span>
<span data-i18n="meals_board_title">Planning Repas</span>
</h1>
<button class="pf-btn" title="Générer les courses">
🛒 <span data-i18n="btn_groceries">Courses</span>
</button>
</div>
<!-- BARRE CONTEXTUELLE FIXE (Évite le conflit avec le clavier mobile) -->
<div class="context-toolbar" id="contextToolbar">
<button
class="ctx-btn"
onmousedown="
event.preventDefault();
applyToAllInRow();
"
>
➡️ <span data-i18n="btn_apply_all">Tous</span>
</button>
<button
class="ctx-btn primary"
onmousedown="
event.preventDefault();
openCopySheetFromCell();
"
>
🔄 <span data-i18n="btn_copy_to">Copier vers...</span>
</button>
</div>
<div class="meals-wrapper">
<table class="meals-table" id="mealsTable">
<thead>
<tr>
<th class="col-sticky"></th>
<th>PAPA</th>
<th>MAMAN</th>
<th>E1</th>
<th>E2</th>
</tr>
</thead>
<tbody>
<!-- Injecté via JS -->
</tbody>
</table>
</div>
<!-- MODALE DE COPIE (GRILLE LUNDI-DIMANCHE) -->
<div id="copyModal" class="pf-modal">
<div class="pf-modal-content">
<div class="modal-header">
<h3 class="modal-title">
🔄 <span data-i18n="modal_copy_title">Copier vers...</span>
</h3>
<button class="modal-close" onclick="closeCopyModal()">
&times;
</button>
</div>
<div id="copySourceLabel" class="modal-source">
Source : Plat individuel (Lundi Midi)
</div>
<div class="week-section-title" data-i18n="week_current">
Semaine actuelle
</div>
<div class="days-grid" id="currentWeekGrid"></div>
<div class="week-section-title" data-i18n="week_next">
Semaine prochaine
</div>
<div class="days-grid" id="nextWeekGrid"></div>
</div>
</div>
<script>
// --- I18N MOCKUP ---
window.I18N = {
meals_board_title: "Planning Repas",
btn_groceries: "Courses",
btn_apply_all: "Tous",
btn_copy_to: "Copier vers...",
modal_copy_title: "Copier vers...",
source_individual: "Plat individuel",
source_row: "Repas complet",
week_current: "Semaine actuelle",
week_next: "Semaine prochaine",
};
document
.querySelectorAll("[data-i18n]")
.forEach(
(el) => (el.innerHTML = window.I18N[el.getAttribute("data-i18n")]),
);
const days = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
const fullDays = [
"Lundi",
"Mardi",
"Mercredi",
"Jeudi",
"Vendredi",
"Samedi",
"Dimanche",
];
const membersCount = 4;
const tbody = document.querySelector("#mealsTable tbody");
let copyState = {
type: null,
dayIdx: null,
service: null,
personIdx: null,
sourceElements: [],
};
// --- 1. GÉNÉRATION DE LA GRILLE PRINCIPALE ---
days.forEach((day, index) => {
let trLunch = document.createElement("tr");
trLunch.className = "row-lunch";
trLunch.innerHTML = `
<td class="col-sticky">
<span class="day-label">${day}</span>
<div class="service-icon" onclick="openCopySheetFromRow(${index}, 'lunch')" title="Copier la ligne">☀️</div>
</td>`;
for (let p = 0; p < membersCount; p++) {
trLunch.innerHTML += `<td class="meal-input-cell"><textarea class="meal-input js-meal-input" data-d="${index}" data-s="lunch" data-p="${p}" placeholder="-"></textarea></td>`;
}
tbody.appendChild(trLunch);
let trDinner = document.createElement("tr");
trDinner.className = "row-dinner";
trDinner.innerHTML = `
<td class="col-sticky">
<span class="day-label invisible">${day}</span>
<div class="service-icon" onclick="openCopySheetFromRow(${index}, 'dinner')" title="Copier la ligne">🌙</div>
</td>`;
for (let p = 0; p < membersCount; p++) {
trDinner.innerHTML += `<td class="meal-input-cell"><textarea class="meal-input js-meal-input" data-d="${index}" data-s="dinner" data-p="${p}" placeholder="-"></textarea></td>`;
}
tbody.appendChild(trDinner);
});
// --- 2. GÉNÉRATION DES GRILLES DE LA MODALE COPIE ---
function buildModalGrid(containerId, weekTarget) {
const container = document.getElementById(containerId);
let html = "";
fullDays.forEach((dayName, idx) => {
html += `
<div class="day-card">
<div class="day-card-title">${dayName}</div>
<div class="day-card-actions">
<button type="button" class="day-action-btn" onclick="executeCopy(${idx}, 'lunch', '${weekTarget}')">☀️</button>
<button type="button" class="day-action-btn" onclick="executeCopy(${idx}, 'dinner', '${weekTarget}')">🌙</button>
</div>
</div>`;
});
container.innerHTML = html;
}
buildModalGrid("currentWeekGrid", "current");
buildModalGrid("nextWeekGrid", "next");
// --- 3. GESTION DU FOCUS & DE LA TOOLBAR ---
document.addEventListener("focusin", (e) => {
if (e.target.classList.contains("js-meal-input")) {
copyState = {
type: "cell",
dayIdx: e.target.getAttribute("data-d"),
service: e.target.getAttribute("data-s"),
personIdx: e.target.getAttribute("data-p"),
sourceElements: [e.target],
};
document.getElementById("contextToolbar").classList.add("active");
}
});
document.addEventListener("focusout", (e) => {
setTimeout(() => {
if (!document.activeElement.classList.contains("js-meal-input")) {
document
.getElementById("contextToolbar")
.classList.remove("active");
}
}, 150);
});
function flashSuccess(input) {
input.style.transition = "background 0.3s";
input.style.background = "var(--alert-success-bg)";
setTimeout(() => (input.style.background = "transparent"), 400);
}
// --- 4. ACTIONS DE COPIE ---
function applyToAllInRow() {
if (
copyState.type !== "cell" ||
!copyState.sourceElements[0].value.trim()
)
return;
const val = copyState.sourceElements[0].value;
const inputs = document.querySelectorAll(
`.js-meal-input[data-d="${copyState.dayIdx}"][data-s="${copyState.service}"]`,
);
inputs.forEach((inp) => {
if (inp !== copyState.sourceElements[0]) {
inp.value = val;
flashSuccess(inp);
}
});
}
function openCopySheetFromCell() {
if (
!copyState.sourceElements[0] ||
!copyState.sourceElements[0].value.trim()
)
return;
document.getElementById("contextToolbar").classList.remove("active");
document.activeElement.blur(); // Masque le clavier mobile
const svcLabel = copyState.service === "lunch" ? "Midi" : "Soir";
document.getElementById("copySourceLabel").innerText =
`Source : ${window.I18N.source_individual} (${fullDays[copyState.dayIdx]} ${svcLabel})`;
document.getElementById("copyModal").classList.add("open");
document.body.classList.add("no-scroll");
}
function openCopySheetFromRow(dIdx, service) {
const inputs = document.querySelectorAll(
`.js-meal-input[data-d="${dIdx}"][data-s="${service}"]`,
);
const hasContent = Array.from(inputs).some(
(inp) => inp.value.trim() !== "",
);
if (!hasContent) return;
copyState = {
type: "row",
dayIdx: dIdx,
service: service,
sourceElements: Array.from(inputs),
};
const svcLabel = service === "lunch" ? "Midi" : "Soir";
document.getElementById("copySourceLabel").innerText =
`Source : ${window.I18N.source_row} (${fullDays[dIdx]} ${svcLabel})`;
document.getElementById("copyModal").classList.add("open");
document.body.classList.add("no-scroll");
}
function closeCopyModal() {
document.getElementById("copyModal").classList.remove("open");
document.body.classList.remove("no-scroll");
}
function executeCopy(targetDayIdx, targetService, targetWeek) {
if (targetWeek === "next") {
alert("Simulation : Copié avec succès vers la SEMAINE PROCHAINE !");
closeCopyModal();
return;
}
if (copyState.type === "cell") {
const val = copyState.sourceElements[0].value;
const targetInput = document.querySelector(
`.js-meal-input[data-d="${targetDayIdx}"][data-s="${targetService}"][data-p="${copyState.personIdx}"]`,
);
if (targetInput) {
targetInput.value = val;
flashSuccess(targetInput);
}
} else if (copyState.type === "row") {
const targetInputs = document.querySelectorAll(
`.js-meal-input[data-d="${targetDayIdx}"][data-s="${targetService}"]`,
);
copyState.sourceElements.forEach((inp, idx) => {
if (inp.value.trim() !== "") {
targetInputs[idx].value = inp.value;
flashSuccess(targetInputs[idx]);
}
});
}
closeCopyModal();
}
</script>
</body>
</html>
+1040 -137
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,260 @@
<?php
// modules/budget/includes/DynamicBudgetCalculator.php
class DynamicBudgetCalculator {
private \PDO $pdo;
private int $year;
private string $month;
private array $settings = [];
private array $foyerSettings = [];
private array $kids = [];
private string $helperName = 'Intervenant'; // Valeur par défaut
private array $calendarEvents = [];
public function __construct(\PDO $pdo, int|string $year, int|string $month) {
$this->pdo = $pdo;
$this->year = (int)$year;
$this->month = str_pad((string)$month, 2, '0', STR_PAD_LEFT);
$this->loadSettings();
$this->loadPeople();
$this->loadEvents();
}
private function loadSettings(): void {
$stmt = $this->pdo->query("SELECT setting_key, setting_value FROM pf_settings WHERE module = 'budget'");
$this->settings = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
$this->foyerSettings = $this->pdo->query("SELECT * FROM pf_foyer_settings LIMIT 1")->fetch(PDO::FETCH_ASSOC);
}
private function getSetting(string $key, float|int $default = 0): float {
return isset($this->settings[$key]) ? (float)$this->settings[$key] : (float)$default;
}
private function loadPeople(): void {
// 1. Chargement des enfants sans doublons
$stmt = $this->pdo->query("SELECT id, name, care_modes FROM pf_people WHERE role IN ('enfant', 'child') AND is_active = 1");
$rawKids = $stmt->fetchAll(PDO::FETCH_ASSOC);
$this->kids = [];
$seenNames = [];
foreach ($rawKids as $k) {
$nameKey = mb_strtolower(trim($k['name']));
if (!isset($seenNames[$nameKey])) {
$this->kids[] = $k;
$seenNames[$nameKey] = true;
}
}
// 2. Chargement dynamique de l'intervenant (ex: Carole)
$stmtHelper = $this->pdo->query("SELECT name FROM pf_people WHERE role IN ('helper', 'nounou') AND is_active = 1 LIMIT 1");
$helper = $stmtHelper->fetchColumn();
if ($helper) {
$this->helperName = mb_strtoupper($helper);
}
}
private function loadEvents(): void {
$startDate = "{$this->year}-{$this->month}-01";
$endDate = date('Y-m-t', strtotime($startDate));
$stmt = $this->pdo->prepare("SELECT event_date, event_type, person_id FROM pf_events WHERE event_date BETWEEN ? AND ?");
$stmt->execute([$startDate, $endDate]);
$events = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($events as $e) {
$this->calendarEvents[$e['event_date']][] = $e;
}
}
private function getOffDays(): array {
$cacheKey = "offdays_{$this->year}";
$stmt = $this->pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'system_cache' AND reference_id = ?");
$stmt->execute([$cacheKey]);
$cached = $stmt->fetchColumn();
if ($cached) return json_decode($cached, true);
$offDays = ['feries' => [], 'vacances' => []];
try {
$jsonFeries = @file_get_contents("https://calendrier.api.gouv.fr/jours-feries/metropole/{$this->year}.json");
if ($jsonFeries) $offDays['feries'] = array_keys(json_decode($jsonFeries, true));
} catch (\Throwable $e) {}
$zone = $this->foyerSettings['zone_scolaire'] ?? 'C';
try {
$url = "https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=annee_scolaire='{$this->year}-" . ($this->year + 1) . "' AND zones LIKE '%Zone {$zone}%'&limit=100";
$jsonVacances = @file_get_contents($url);
if ($jsonVacances) {
$data = json_decode($jsonVacances, true);
if (!empty($data['results'])) {
foreach ($data['results'] as $r) {
$start = new DateTime(substr($r['start_date'], 0, 10));
$end = new DateTime(substr($r['end_date'], 0, 10));
if ($start->format('N') == 5) $start->modify('+1 day');
while ($start < $end) {
$offDays['vacances'][] = $start->format('Y-m-d');
$start->modify('+1 day');
}
}
}
}
} catch (\Throwable $e) {}
$this->pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('system_cache', ?, ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")
->execute([$cacheKey, json_encode($offDays)]);
return $offDays;
}
public function getEstimate(string $dynamicCode): array {
if ($dynamicCode === 'SCHOOL_ESTIMATE') {
return $this->calculateSchoolEstimate();
}
return ['amount' => 0.0, 'details' => tr('error_occured')];
}
private function calculateSchoolEstimate(): array {
$nannyFixed = $this->getSetting('budget_nanny_fixed', 700);
$nannyDaily = $this->getSetting('budget_nanny_daily', 4);
$nannyAid = $this->getSetting('budget_nanny_aid', 120);
$cesuAvg = $this->getSetting('budget_nanny_cesu_avg', 170);
$schoolMeal = $this->getSetting('budget_school_meal', 5.62);
$schoolAftercare = $this->getSetting('budget_school_aftercare', 1.97);
$schoolFullday = $this->getSetting('budget_school_fullday', 21.89);
$offDays = $this->getOffDays();
$totalCost = 0;
$details = sprintf(tr('bud_audit_month') . "\n\n", $this->month, $this->year);
$hasNanny = false;
$nannyOffDays = 0;
$nannyGlobalCost = $nannyFixed - $nannyAid - $cesuAvg;
$kidsStats = [];
// 1. Initialisation stricte
foreach ($this->kids as $k) {
$modes = json_decode($k['care_modes'] ?? '[]', true) ?: [];
$isNanny = count(array_intersect(array_map('strtolower', $modes), ['nounou', 'kangur'])) > 0;
if ($isNanny) $hasNanny = true;
$kidsStats[$k['id']] = [
'name' => $k['name'], 'isNanny' => $isNanny,
'nannyDays' => 0, 'sick' => 0,
'schoolMeal' => 0, 'schoolAftercare' => 0, 'center' => 0
];
}
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, (int)$this->month, $this->year);
// 2. Itération SANS pointeurs de référence
for ($d = 1; $d <= $daysInMonth; $d++) {
$dateStr = sprintf("%04d-%02d-%02d", $this->year, $this->month, $d);
$dayOfWeek = date('N', strtotime($dateStr));
if ($dayOfWeek >= 6) continue;
if (in_array($dateStr, $offDays['feries'])) continue;
$isVacances = in_array($dateStr, $offDays['vacances']);
$events = $this->calendarEvents[$dateStr] ?? [];
$nannyOffToday = false;
foreach ($events as $e) {
if (in_array($e['event_type'], ['HELPER_OFF', 'HELPER_EXTRA'])) $nannyOffToday = true;
}
if ($nannyOffToday) $nannyOffDays++;
foreach ($this->kids as $kid) {
$kidId = $kid['id'];
$isSick = false;
$hasCenterEvent = false;
foreach ($events as $e) {
if ($e['event_type'] === 'CHILD_SICK' && $e['person_id'] == $kidId) $isSick = true;
if (strtoupper($e['event_type']) === 'CENTRE' && $e['person_id'] == $kidId) $hasCenterEvent = true;
}
if ($isSick) {
$kidsStats[$kidId]['sick']++;
continue;
}
if ($kidsStats[$kidId]['isNanny']) {
if (!$nannyOffToday) $kidsStats[$kidId]['nannyDays']++;
} else {
if ($isVacances) {
if ($hasCenterEvent) $kidsStats[$kidId]['center']++;
} else {
if ($dayOfWeek == 3) {
$kidsStats[$kidId]['center']++;
} else {
$kidsStats[$kidId]['schoolMeal']++;
if (in_array($dayOfWeek, [2, 4])) $kidsStats[$kidId]['schoolAftercare']++;
}
}
}
}
}
$totalNannyDays = 0;
foreach ($kidsStats as $stat) {
if ($stat['isNanny']) {
$totalNannyDays += $stat['nannyDays'];
}
}
$totalNannyFees = $totalNannyDays * $nannyDaily;
$nannyGlobalCost = $nannyFixed + $totalNannyFees - $nannyAid - $cesuAvg;
// 3. Rédaction du Bilan
if ($hasNanny) {
$totalCost += max(0, $nannyGlobalCost);
$details .= sprintf(tr('bud_audit_nanny_full') . "\n",
$this->helperName, $nannyFixed, $totalNannyDays, $nannyDaily, $totalNannyFees, $nannyAid, $cesuAvg, max(0, $nannyGlobalCost));
if ($nannyOffDays > 0) {
$details .= sprintf(tr('bud_audit_nanny_abs') . "\n", $this->helperName, $nannyOffDays);
}
$details .= "\n";
}
foreach ($kidsStats as $finalStat) {
if ($finalStat['isNanny']) {
// L'enfant est chez la nounou : on ne l'affiche QUE s'il a été malade
if ($finalStat['sick'] > 0) {
$details .= sprintf(tr('bud_audit_child') . "\n", mb_strtoupper($finalStat['name']));
$details .= sprintf(tr('bud_audit_sick') . "\n", $finalStat['sick']) . "\n\n";
}
} else {
// L'enfant va à l'école / centre
$details .= sprintf(tr('bud_audit_child') . "\n", mb_strtoupper($finalStat['name']));
$costM = $finalStat['schoolMeal'] * $schoolMeal;
$costA = $finalStat['schoolAftercare'] * $schoolAftercare;
$costC = $finalStat['center'] * $schoolFullday;
$kidTotal = $costM + $costA + $costC;
$totalCost += $kidTotal;
$details .= sprintf(tr('bud_audit_school_fee') . "\n",
$finalStat['schoolMeal'], $finalStat['schoolAftercare'], $finalStat['center'], $kidTotal);
if ($finalStat['sick'] > 0) {
$details .= sprintf(tr('bud_audit_sick') . "\n", $finalStat['sick']) . "\n";
}
$details .= "\n";
}
}
return [
'amount' => round($totalCost, 2),
'details' => trim($details)
];
}
}
@@ -0,0 +1,30 @@
<?php
header('Content-Type: application/json');
// On charge l'authentification et l'init PDO de l'espace familial courant automatiquement via tes includes globaux
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$title = trim($_POST['title'] ?? '');
$amount = floatval($_POST['amount'] ?? 0);
$expected_date = $_POST['expected_date'] ?? '';
if (empty($title) || $amount <= 0 || empty($expected_date)) {
echo json_encode(['success' => false, 'message' => tr('error_generic')]);
exit;
}
try {
// Grâce au multi-tenant par BDD, $pdo pointe déjà sur la base de la famille connectée
$stmt = $pdo->prepare("INSERT INTO pf_expected_expenses (title, amount, expected_date) VALUES (?, ?, ?)");
$stmt->execute([$title, $amount, $expected_date]);
echo json_encode(['success' => true, 'message' => tr('success_add_provision')]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]);
}
@@ -0,0 +1,19 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
$id = intval($_POST['id'] ?? 0);
if (!$id) {
echo json_encode(['success' => false, 'message' => 'Invalid ID']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM pf_expected_expenses WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
@@ -0,0 +1,35 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.php';
try {
// Récupérer les provisions non payées, classées par date
$stmt = $pdo->query("SELECT id, title, amount, expected_date FROM pf_expected_expenses WHERE is_paid = 0 ORDER BY expected_date ASC");
$provisions = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($provisions)) {
$html = '<p class="pf-text-muted">' . tr('no_provisions') . '</p>';
} else {
$html = '<table class="pf-table" style="width:100%; border-collapse: collapse;">';
$html .= '<thead style="border-bottom: 2px solid var(--border-color); text-align: left;">';
$html .= '<tr><th>' . tr('provision_label') . '</th><th>' . tr('amount') . '</th><th>' . tr('expected_date') . '</th><th style="text-align:right;">Actions</th></tr>';
$html .= '</thead><tbody>';
foreach ($provisions as $p) {
$dateFormated = date('d/m/Y', strtotime($p['expected_date']));
$html .= '<tr style="border-bottom: 1px solid var(--border-color); height: 45px;">';
$html .= '<td>' . htmlspecialchars($p['title']) . '</td>';
$html .= '<td>' . number_format($p['amount'], 2, ',', ' ') . ' €</td>';
$html .= '<td>' . $dateFormated . '</td>';
$html .= '<td style="text-align:right;"><button class="pf-btn pf-btn-danger btn-delete-provision" data-id="' . $p['id'] . '">' . tr('btn_delete') . '</button></td>';
$html .= '</tr>';
}
$html .= '</tbody></table>';
}
echo json_encode(['success' => true, 'html' => $html]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
@@ -0,0 +1,156 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.php';
$savings_inputs = $_POST['savings'] ?? [];
$current_year = date('Y');
$current_month = date('m');
try {
// 1. Récupération des dépenses prévues
$stmtExpenses = $pdo->prepare("
SELECT title, amount, expected_date
FROM pf_expected_expenses
WHERE is_paid = 0
AND YEAR(expected_date) = ?
AND MONTH(expected_date) = ?
ORDER BY expected_date ASC
");
$stmtExpenses->execute([$current_year, $current_month]);
$expenses = $stmtExpenses->fetchAll(PDO::FETCH_ASSOC);
$q1_expenses_total = 0;
$q2_expenses_total = 0;
$detail_html = '<ul style="padding-left: 1.2rem; margin-bottom: 1rem; color: var(--text-muted); font-size:0.9rem;">';
foreach ($expenses as $e) {
$day = intval(date('d', strtotime($e['expected_date'])));
if ($day <= 15) {
$q1_expenses_total += $e['amount'];
} else {
$q2_expenses_total += $e['amount'];
}
$detail_html .= '<li>' . htmlspecialchars($e['title']) . ' (' . number_format($e['amount'], 2, ',', ' ') . ' € le ' . date('d/m', strtotime($e['expected_date'])) . ')</li>';
}
$detail_html .= '</ul>';
$total_expenses = $q1_expenses_total + $q2_expenses_total;
// 2. Récupération des dettes
$stmtDebts = $pdo->query("SELECT payer, SUM(amount) as total_debt FROM pf_advances WHERE is_resolved = 0 GROUP BY payer");
$debts = $stmtDebts->fetchAll(PDO::FETCH_KEY_PAIR);
// 3. NOUVELLE LOGIQUE : Clearing intelligent (Compensation partielle ou totale)
$total_base_inputs = array_sum(array_map('floatval', $savings_inputs));
$surplus_for_clearing = $total_base_inputs - $total_expenses;
$monthly_savings = 0; // Le vrai total qui sera viré après déduction des dettes
$contributions_html = '<ul style="padding-left: 1.2rem; margin-bottom: 0.5rem; color: var(--text-main); font-size:0.9rem; line-height: 1.6;">';
foreach ($savings_inputs as $person => $amount) {
$base_amount = floatval($amount);
$debt = $debts[$person] ?? 0;
// Formatage bancaire propre pour les textes
$base_fmt = number_format($base_amount, 2, ',', ' ');
if ($debt > 0) {
if ($surplus_for_clearing > 0) {
$compensation = min($debt, $base_amount, $surplus_for_clearing);
$net_amount = $base_amount - $compensation;
$surplus_for_clearing -= $compensation;
$monthly_savings += $net_amount;
$comp_fmt = number_format($compensation, 2, ',', ' ');
$net_fmt = number_format($net_amount, 2, ',', ' ');
$reste_dette = $debt - $compensation;
$txt_reste = ($reste_dette > 0)
? ' <span style="color:var(--danger); font-size:0.8rem; margin-left: 5px;">(Reste ' . number_format($reste_dette, 2, ',', ' ') . ' € de dette)</span>'
: ' <span style="color:var(--success); font-weight:bold; font-size:0.85rem; margin-left: 5px;">(Dette soldée 🎉)</span>';
// Harmonisation en bleu (#2563eb) pour le montant net à verser
$contributions_html .= '<li style="margin-bottom: 5px;"><strong>' . htmlspecialchars($person) . '</strong> : Base ' . $base_fmt . ' € − Remboursé ' . $comp_fmt . ' € = <strong style="color:#2563eb;">' . $net_fmt . ' € à verser</strong>' . $txt_reste . '</li>';
} else {
$monthly_savings += $base_amount;
$contributions_html .= '<li style="margin-bottom: 5px;"><strong>' . htmlspecialchars($person) . '</strong> : <strong style="color:#2563eb;">' . $base_fmt . ' € à verser</strong> <span style="color:var(--danger); font-size:0.8rem; margin-left: 5px;">(Dette gelée, liquidités insuffisantes)</span></li>';
}
} else {
$monthly_savings += $base_amount;
$contributions_html .= '<li style="margin-bottom: 5px;"><strong>' . htmlspecialchars($person) . '</strong> : <strong style="color:#2563eb;">' . $base_fmt . ' € à verser</strong></li>';
}
}
$contributions_html .= '</ul>';
$contributions_html .= '<p style="color: var(--text-main); font-weight: bold; margin-top:5px; font-size:0.95rem;">Total net atterrissant sur les comptes : ' . number_format($monthly_savings, 2, ',', ' ') . ' €</p>';
if(empty($expenses)) {
$detail_html = '<p style="color: var(--success); font-weight:bold; margin-bottom:1rem; font-size:0.9rem;">🎉 Aucune grosse dépense enregistrée sur ce mois.</p>';
}
// --- MOTEUR DE RÈGLES DES QUINZAINES (Remplacement des ** par <strong>) ---
$instructions = [];
$remaining_to_allocate = $monthly_savings;
// Règle 1 : Première quinzaine
if ($q1_expenses_total > 0) {
if ($remaining_to_allocate >= $q1_expenses_total) {
$remaining_to_allocate -= $q1_expenses_total;
$instructions[] = "💼 <strong>Dès le 1er</strong> : Laissez <strong>" . number_format($q1_expenses_total, 2, ',', ' ') . " €</strong> sur le compte commun pour honorer les dépenses de la 1ère quinzaine.";
} else {
$deficit = $q1_expenses_total - $remaining_to_allocate;
$remaining_to_allocate = 0;
$instructions[] = "💼 <strong>Dès le 1er</strong> : Gardez l'intégralité des apports sur le compte commun.";
$instructions[] = "⚠️ <strong>Retrait requis</strong> : Retirez <strong>" . number_format($deficit, 2, ',', ' ') . " €</strong> depuis le Livret A vers le commun pour couvrir la 1ère quinzaine.";
}
}
// Règle 2 : Deuxième quinzaine
if ($q2_expenses_total > 0) {
if ($remaining_to_allocate >= $q2_expenses_total) {
$remaining_to_allocate -= $q2_expenses_total;
$instructions[] = "📈 <strong>Dès le 1er</strong> : Placez <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> sur le Livret A pour générer des intérêts sur la 1ère quinzaine.";
$instructions[] = "🔄 <strong>Le 16 du mois</strong> : Transférez ces <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> sur le compte commun pour payer la dépense.";
} else {
if ($remaining_to_allocate > 0) {
$instructions[] = "📈 <strong>Dès le 1er</strong> : Placez le reste des apports (<strong>" . number_format($remaining_to_allocate, 2, ',', ' ') . " €</strong>) sur le Livret A.";
$instructions[] = "🔄 <strong>Le 16 du mois</strong> : Retirez <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> global du Livret A pour payer la fin de mois.";
$remaining_to_allocate = 0;
} else {
$instructions[] = "🔄 <strong>Le 16 du mois</strong> : Retirez <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> du Livret A vers le commun. <em>Ne le faites pas avant le 16 !</em>";
}
}
}
// Solde résiduel long terme
if ($remaining_to_allocate > 0) {
$instructions[] = "💰 <strong>Épargne stable</strong> : Placez les <strong>" . number_format($remaining_to_allocate, 2, ',', ' ') . " €</strong> restants sur votre Livret A dès le 1er.";
}
// --- CONSTITUTION DU RENDU HTML FINAL ---
$html = '<div style="border-top: 1px dashed var(--border-color); padding-top:0.8rem;">';
// Titre de l'état du Clearing simplifié et clair
$html .= '<h4 style="margin:0 0 0.4rem 0; font-size:0.95rem;">🤝 Résumé des apports et remboursements internes :</h4>';
$html .= '<div style="background: var(--bg-main); padding: 8px; border-radius: 6px; margin-bottom: 1rem; border: 1px solid var(--border-color);">';
$html .= $contributions_html;
$html .= '</div>';
$html .= '<h4 style="margin:0 0 0.4rem 0; font-size:0.95rem;">Analyse des dépenses du mois :</h4>';
$html .= $detail_html;
$html .= '<div style="background: var(--bg-main); padding: 8px; border-radius: 6px; border-left: 4px solid var(--accent-color);">';
$html .= '<h4 style="margin:0 0 0.5rem 0; color: var(--accent-color); font-size:0.95rem;">📋 Plan d\'action recommandé :</h4>';
foreach ($instructions as $ins) {
$html .= '<p style="margin:0 0 0.4rem 0; font-size: 0.9rem; line-height: 1.4;">' . $ins . '</p>';
}
$html .= '</div></div>';
echo json_encode(['success' => true, 'html' => $html]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'Calcul impossible : ' . $e->getMessage()]);
}
+25 -2
View File
@@ -197,6 +197,26 @@ if ($action === 'validate_transfers') {
$dbPersonName = $stmtP->fetchColumn();
if (!$dbPersonName) throw new Exception("Parent introuvable.");
$stmtAcc = $pdo->query("SELECT a.name as acc_name, p.name as owner_name FROM pf_bank_accounts a LEFT JOIN pf_people p ON a.owner_person_id = p.id");
$bankAccountsMap = [];
// Fallback intelligent : on récupère tous les prénoms de la famille
$familyNames = $pdo->query("SELECT name FROM pf_people")->fetchAll(PDO::FETCH_COLUMN);
while ($row = $stmtAcc->fetch(PDO::FETCH_ASSOC)) {
$owner = $row['owner_name'];
if (!$owner) {
// Si pas de propriétaire explicite, on cherche un prénom dans le nom du compte (ex: "Livret A Alex")
foreach ($familyNames as $pName) {
if (stripos($row['acc_name'], $pName) !== false) {
$owner = $pName;
break;
}
}
}
$bankAccountsMap['vers ' . $row['acc_name']] = $owner;
}
$stmt = $pdo->prepare("
SELECT v.amount, c.name as cat_name, c.transfer_dest, c.holiday_id
FROM pf_alloc_values v
@@ -217,7 +237,10 @@ if ($action === 'validate_transfers') {
$holidayId = $line['holiday_id'];
$targetOwner = null;
if ($dest === 'vers L.Perso') { $targetOwner = $dbPersonName; }
if (isset($bankAccountsMap[$dest])) {
$targetOwner = $bankAccountsMap[$dest];
if (!$targetOwner) continue; // Compte commun sans propriétaire
} elseif ($dest === 'vers L.Perso') { $targetOwner = $dbPersonName; }
elseif ($dest === 'vers L.Pol') { $targetOwner = 'Pol'; }
elseif ($dest === 'vers L.Pep') { $targetOwner = 'Pep'; }
elseif ($dest === 'vers commune') { continue; }
@@ -261,7 +284,7 @@ if ($action === 'validate_transfers') {
$catAmount = $catInfo['amount'];
$catHolidayId = $catInfo['holiday_id'];
if (strpos($catName, 'Eco P') === 0) { continue; }
if (preg_match('/^(Eco\s|Eco P\d|Livret)/i', $catName)) { continue; }
$stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
$stmtCheckCat->execute([$owner, $monthDate, $catName]);
+3 -3
View File
@@ -20,14 +20,14 @@ if ($action === 'update_single_entry') {
$cat = $_POST['category'] ?? '';
$owner = $_POST['owner'] ?? '';
$amount = (float)($_POST['amount'] ?? 0);
$forceInsert = !empty($_POST['force_insert']);
try {
if ($amount == 0 && $cat !== 'TOTAL_BANQUE') {
// Si on met à 0 une ligne (autre que le total), on supprime l'entrée pour garder la base propre
// On supprime la ligne si elle est à 0, SAUF si on demande explicitement son initialisation (force_insert)
if ($amount == 0 && $cat !== 'TOTAL_BANQUE' && !$forceInsert) {
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE month_date=? AND owner=? AND category=?");
$stmt->execute([$month, $owner, $cat]);
} else {
// Sinon on insère ou on met à jour
$stmt = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE amount = VALUES(amount)");
$stmt->execute([$month, $owner, $cat, $amount]);
}
+51 -10
View File
@@ -1,6 +1,7 @@
<?php
require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.php';
require_login();
header('Content-Type: application/json');
@@ -45,16 +46,25 @@ try {
$currencySetting = $foyerData['currency'] ?? '€';
$csvMapping = !empty($foyerData['csv_mapping']) ? json_decode($foyerData['csv_mapping'], true) : null;
// --- NOUVEAU : Récupération des paramètres dynamiques ---
$stmtSettings = $pdo->query("SELECT setting_key, setting_value FROM pf_settings WHERE module = 'budget'");
$budgetSettings = $stmtSettings->fetchAll(PDO::FETCH_KEY_PAIR);
// 6. Liste des membres (pour assigner un compte bancaire)
$people = $pdo->query("SELECT id, name FROM pf_people WHERE is_active = 1 ORDER BY name ASC")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'success' => true,
'data' => [
'accounts' => $accounts,
'categories' => $categories,
'rules' => $rules,
'salaries' => $salaries,
'year' => $currentYear,
'currency' => $currencySetting,
'csv_mapping' => $csvMapping
'accounts' => $accounts,
'categories' => $categories,
'rules' => $rules,
'salaries' => $salaries,
'people' => $people,
'year' => $currentYear,
'currency' => $currencySetting,
'csv_mapping' => $csvMapping,
'budget_settings' => $budgetSettings
]
]);
exit;
@@ -64,9 +74,10 @@ try {
if ($action === 'add_account') {
$name = trim($_POST['name'] ?? '');
$type = $_POST['type'] ?? 'checking';
$owner_id = !empty($_POST['owner_person_id']) ? (int)$_POST['owner_person_id'] : null;
if (empty($name)) throw new Exception("Le nom du compte est obligatoire.");
$stmt = $pdo->prepare("INSERT INTO pf_bank_accounts (name, account_type, is_default) VALUES (?, ?, 0)");
$stmt->execute([$name, $type]);
$stmt = $pdo->prepare("INSERT INTO pf_bank_accounts (name, account_type, is_default, owner_person_id) VALUES (?, ?, 0, ?)");
$stmt->execute([$name, $type, $owner_id]);
echo json_encode(['success' => true]);
exit;
}
@@ -100,6 +111,15 @@ try {
if ($action === 'delete_category') {
$id = (int)($_POST['id'] ?? 0);
$stmtCheck = $pdo->prepare("SELECT code FROM pf_budget_categories WHERE id = ?");
$stmtCheck->execute([$id]);
$catCode = $stmtCheck->fetchColumn();
if ($catCode && in_array(strtoupper($catCode), ['FIXED', 'INCOME'])) {
throw new Exception(tr('bud_err_delete_system_cat'));
}
$stmt = $pdo->prepare("DELETE FROM pf_budget_categories WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
@@ -181,9 +201,30 @@ try {
exit;
}
// --- GESTION DES ESTIMATIONS DYNAMIQUES ---
if ($action === 'save_dynamic_estimates') {
$keys = [
'budget_nanny_fixed', 'budget_nanny_daily', 'budget_nanny_aid', 'budget_nanny_cesu_avg',
'budget_school_meal', 'budget_school_aftercare', 'budget_school_fullday'
];
$pdo->beginTransaction();
$stmt = $pdo->prepare("INSERT INTO pf_settings (setting_key, setting_value, module) VALUES (?, ?, 'budget') ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()");
foreach ($keys as $k) {
if (isset($_POST[$k])) {
$stmt->execute([$k, $_POST[$k]]);
}
}
$pdo->commit();
echo json_encode(['success' => true]);
exit;
}
throw new Exception("Action API inconnue.");
} catch (Exception $e) {
} catch (\Throwable $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+11 -11
View File
@@ -1,8 +1,8 @@
<?php
// modules/budget/views/budget_prev.php
// 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");
// 1. Chargement dynamique des ADULTES de la famille (Exclusion stricte des rôles secondaires)
$stmtPeople = $pdo->query("SELECT id, name, user_id, role, color FROM pf_people WHERE role IN ('parent') AND is_active = 1 ORDER BY id ASC");
$budgetParents = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
// Sécurité : au cas où aucun adulte n'est trouvé, on évite un crash
@@ -277,7 +277,7 @@ if (count($parentMapping) >= 2) {
</tr>
<?php foreach ($cats as $cat):
$isIndicative = (strpos($cat['name'], 'Eco P') === 0);
$isIndicative = (preg_match('/^(Eco\s|Eco P\d|Livret)/i', $cat['name']) === 1);
$rowClass = $isIndicative ? 'row-indicative' : '';
$inputClass = $isIndicative ? 'ignore-calc' : '';
@@ -306,7 +306,7 @@ if (count($parentMapping) >= 2) {
data-transfer-dest="<?= htmlspecialchars($cat['transfer_dest'] ?? '') ?>"
data-holiday="<?= $cat['holiday_id'] ?? '' ?>"
onclick="openEditModal(this)">✎</button>
<button type="button" onclick="deleteCategory(<?= $cat['id'] ?>)" class="btn-icon-action delete" title="<?= tr('delete') ?>">🗑️</button>
<button type="button" onclick="deleteAllocCategory(<?= $cat['id'] ?>)" class="btn-icon-action delete" title="<?= tr('delete') ?>">🗑️</button>
</div>
</td>
<?php foreach ($months as $m): ?>
@@ -794,7 +794,7 @@ function duplicateMonth() {
const parentMap = window.CONFIG.parentMapping;
if (!sourceDateStr) {
alert(tr('bud_prev_err_no_history'));
showToast(tr('bud_prev_err_no_history'), 'error');
return;
}
@@ -955,10 +955,10 @@ async function validateTransfers(personCss, month) {
if(result.success) {
window.location.reload();
} else {
alert(tr('bud_err_tech') + " : " + result.error);
showToast(tr('bud_err_tech') + " : " + (result.error || "Inconnue"), 'error');
}
} catch(e) {
alert(tr('bud_err_tech'));
showToast(tr('bud_err_tech'), 'error');
}
}
@@ -986,7 +986,7 @@ async function saveGenericNote(noteType, refId, content) {
}
}
async function deleteCategory(id) {
async function deleteAllocCategory(id) {
if (!confirm(tr('bud_prev_confirm_del_line'))) return;
const formData = new FormData();
formData.append('action', 'delete_category');
@@ -1035,13 +1035,13 @@ async function handleAdvanceSubmit(event, form) {
if (result.success) {
window.location.reload();
} else {
alert((tr('bud_err_tech') || "Erreur") + " : " + (result.error || "Opération échouée"));
showToast((tr('bud_err_tech') || "Erreur") + " : " + (result.error || "Opération échouée"), 'error');
btnSubmit.disabled = false;
btnSubmit.innerHTML = oldText;
}
} catch (err) {
console.error("AJAX Error:", err);
alert(tr('bud_err_tech') || "Une erreur technique est survenue.");
showToast(tr('bud_err_tech') || "Une erreur technique est survenue.", 'error');
btnSubmit.disabled = false;
btnSubmit.innerHTML = oldText;
}
@@ -1155,7 +1155,7 @@ document.querySelectorAll('#addCatModal form, #editCatModal form').forEach(form
alert((tr('bud_err_tech') || 'Erreur') + " : " + (result.error || "Inconnue"));
}
} catch (error) {
alert(tr('bud_err_tech') || "Une erreur technique est survenue.");
showToast(tr('bud_err_tech') || "Une erreur technique est survenue.", 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerText = originalText;
+185 -183
View File
@@ -2,24 +2,24 @@
// modules/budget/views/epargne.php
// --- 🧠 CONFIGURATION AGNOSTIQUE DES MEMBRES (MULTI-TENANT) ---
$stmtPeople = $pdo->query("SELECT name, role FROM pf_people ORDER BY id ASC");
$stmtPeople = $pdo->query("SELECT name, role, color FROM pf_people ORDER BY id ASC");
$familyParents = [];
$familyKids = [];
$personColors = [];
while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) {
$role = strtolower(trim($row['role'] ?? ''));
$personColors[$row['name']] = $row['color'] ?? '#3b82f6';
if ($role === 'parent') {
$familyParents[] = $row['name'];
} elseif ($role === 'nounou' || $role === 'helper') {
// On ignore la nounou dans l'épargne
continue;
} else {
$familyKids[] = $row['name'];
}
}
// Sécurité anti-page blanche si la BDD est mal configurée
if (empty($familyParents)) $familyParents = ['Parent 1', 'Parent 2'];
$requestedOwner = $_GET['owner'] ?? (!empty($familyKids) ? 'KIDS' : ($familyParents[0] ?? 'KIDS'));
@@ -46,10 +46,9 @@ function getMonthName($dateString) {
<div class="budget-view">
<div class="view-header">
<div class="owner-tabs">
<?php if (!empty($familyKids)): ?>
<a href="?tab=epargne&owner=KIDS" class="owner-tab <?= $requestedOwner === 'KIDS' ? 'active' : '' ?>">
<?= tr('budget_tab_kids') ?? 'Enfants 👶' ?>
<?= tr('budget_tab_kids') ?>
</a>
<?php endif; ?>
@@ -81,181 +80,174 @@ function getMonthName($dateString) {
$months = array_slice($months, 0, 7);
sort($allCategories);
$ownerTextClass = 'txt-global';
// 🔄 CORRECTION ICI : Création d'un nom "safe" sans espace pour les classes CSS
$safeOwnerCls = htmlspecialchars(str_replace(' ', '_', $currentOwner), ENT_QUOTES);
$ownerColor = $personColors[$currentOwner] ?? '#1e293b';
?>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; margin-top: <?= ($requestedOwner === 'KIDS' && $index > 0) ? '40px' : '0' ?>;">
<div style="flex-grow: 1;">
<?php if ($requestedOwner === 'KIDS'):
$themeClass = 'theme-' . strtolower($safeOwnerCls);
?>
<h3 class="nens-title <?= $themeClass ?>" style="margin:0; font-size:1.2rem;">
<div class="epargne-header-bar <?= ($requestedOwner === 'KIDS' && $index > 0) ? 'pf-mt-40' : '' ?>">
<div class="pf-flex-1">
<?php if ($requestedOwner === 'KIDS'): ?>
<h3 class="nens-title" style="--owner-color: <?= htmlspecialchars($ownerColor) ?>;">
<?= htmlspecialchars($currentOwner) ?>
</h3>
<?php endif; ?>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<div class="pf-flex-gap-12-center">
<?php if (!empty($months)): ?>
<button onclick="duplicateLastMonth('<?= $months[0] ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn btn-secondary">
🔁 <?= tr('bud_sav_add_one_month') ?>
</button>
<button onclick="promptNewSavingsLine('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', '<?= $months[0] ?>')" class="pf-btn">
+ <?= tr('bud_sav_new_line') ?>
</button>
<?php else: ?>
<button onclick="openCustomSavingsModal('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn">
+ <?= tr('bud_sav_add_month') ?>
</button>
<?php endif; ?>
<button onclick="openCustomSavingsModal('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn">
+ <?= tr('bud_sav_add_month') ?>
</button>
</div>
</div>
<div class="table-responsive" style="background:white; border-radius:16px; box-shadow:var(--shadow-sm); border:1px solid #e2e8f0;">
<div class="table-responsive pf-card-epargne">
<?php if (empty($months)): ?>
<div style="padding: 30px; text-align: center; color: #64748b;">
<div class="pf-empty-dashed">
<p><?= sprintf(tr('bud_sav_no_data'), htmlspecialchars($currentOwner)) ?></p>
</div>
<?php else: ?>
<table class="pf-table savings-table nens-table theme-<?= strtolower($safeOwnerCls) ?>" style="margin-top:0; box-shadow:none; border-radius:16px;">
<thead>
<tr>
<th class="sticky-col" style="background:#f8fafc;"><?= tr('bud_sav_post_month') ?></th>
<?php foreach ($months as $month): ?>
<th>
<div class="month-header-container" style="display: flex; flex-direction: column; align-items: center; gap: 6px;">
<div style="display:flex; flex-direction:column; text-align:center;">
<span class="month-name" style="text-transform:capitalize;"><?= getMonthName($month) ?></span>
<?php
if (isset($cycleConfigs[$month]) && !empty($cycleConfigs[$month]['start_date'])) {
$cStart = date('d/m', strtotime($cycleConfigs[$month]['start_date']));
echo "<span style='font-size:0.75rem; font-weight:normal; color:#64748b;'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</span>";
}
?>
</div>
<div class="month-actions" style="justify-content: center; width: 100%;">
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_edit_modal') ?>"
data-json="<?= htmlspecialchars(json_encode($data[$month] ?? []), ENT_QUOTES, 'UTF-8') ?>"
onclick='editCustomSavingsMonth("<?= $month ?>", "<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>", JSON.parse(this.getAttribute("data-json")))'>
✏️
</button>
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_delete_month') ?>"
onclick="deleteEntireMonth('<?= $month ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')"
style="color: #ef4444; border-color: #fca5a5; background: #fef2f2;">
🗑️
</button>
</div>
</div>
</th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<tr class="row-total">
<td class="sticky-col"><strong><?= tr('bud_sav_total_bank') ?></strong></td>
<?php foreach ($months as $month):
$val = $data[$month]['TOTAL_BANQUE'] ?? 0;
?>
<td class="text-center" style="padding:4px;">
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
<input type="number" step="0.01"
class="prev-input total-input-<?= $safeOwnerCls ?>-<?= $month ?>"
style="width: 70px; font-weight:bold; color:#2563eb;"
value="<?= $val != 0 ? round($val) : '' ?>"
placeholder="0"
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
<span style="color:#2563eb; font-weight:bold; font-size:0.9rem;">€</span>
</div>
</td>
<?php endforeach; ?>
</tr>
<div class="epargne-grid-table" style="--cols: <?= count($months) ?>; --owner-color: <?= htmlspecialchars($ownerColor) ?>;">
<?php foreach ($allCategories as $cat): ?>
<tr>
<td class="sticky-col"><?= htmlspecialchars($cat) ?></td>
<?php foreach ($months as $month):
$amount = $data[$month][$cat] ?? 0;
?>
<td class="text-center" style="padding:4px;">
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
<input type="number" step="0.01"
class="prev-input <?= $ownerTextClass ?> cat-input-<?= $safeOwnerCls ?>-<?= $month ?>"
style="width: 70px;"
value="<?= $amount != 0 ? round($amount) : '' ?>"
placeholder="-"
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
<div class="eg-header-row">
<div class="eg-cell eg-sticky"><?= tr('bud_sav_post_month') ?></div>
<?php foreach ($months as $month): ?>
<div class="eg-cell">
<div class="month-header-container">
<div class="month-header-title">
<span class="month-name text-inherit"><?= getMonthName($month) ?></span>
<?php
if (isset($cycleConfigs[$month]) && !empty($cycleConfigs[$month]['start_date'])) {
$cStart = date('d/m', strtotime($cycleConfigs[$month]['start_date']));
echo "<span class='month-cycle-start'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</span>";
}
?>
</div>
</td>
<?php endforeach; ?>
</tr>
<div class="month-actions">
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_edit_modal') ?>"
data-json="<?= htmlspecialchars(json_encode($data[$month] ?? []), ENT_QUOTES, 'UTF-8') ?>"
onclick='editCustomSavingsMonth("<?= $month ?>", "<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>", JSON.parse(this.getAttribute("data-json")))'>
✏️
</button>
<button class="btn-icon-small btn-safe-click btn-danger-soft" title="<?= tr('bud_sav_delete_month') ?>"
onclick="deleteEntireMonth('<?= $month ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')">
🗑️
</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<tr class="row-extres">
<td class="sticky-col"><strong><?= tr('bud_sav_extra') ?></strong></td>
<?php foreach ($months as $month):
$total = $data[$month]['TOTAL_BANQUE'] ?? 0;
$sum = 0;
foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0);
$extra = $total - $sum;
?>
<td class="text-center font-bold sum-target" id="extra_<?= $safeOwnerCls ?>_<?= $month ?>" style="color: <?= $extra >= 0 ? '#10b981' : '#ef4444' ?>; padding:12px;">
<?= number_format($extra, 0, ',', ' ') ?> €
</td>
<?php endforeach; ?>
</tr>
</tbody>
</table>
<div class="eg-row eg-total-row">
<div class="eg-cell eg-sticky"><strong><?= tr('bud_sav_total_bank') ?></strong></div>
<?php foreach ($months as $month):
$val = $data[$month]['TOTAL_BANQUE'] ?? 0;
?>
<div class="eg-cell">
<div class="eg-cell-content">
<input type="number" step="0.01"
class="prev-input eg-input eg-input-total total-input-<?= $safeOwnerCls ?>-<?= $month ?>"
value="<?= $val != 0 ? round($val) : '' ?>"
placeholder="0"
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
<span class="eg-currency-symbol">€</span>
</div>
</div>
<?php endforeach; ?>
</div>
<?php foreach ($allCategories as $cat): ?>
<div class="eg-row">
<div class="eg-cell eg-sticky"><?= htmlspecialchars($cat) ?></div>
<?php foreach ($months as $month):
$amount = $data[$month][$cat] ?? 0;
?>
<div class="eg-cell">
<input type="number" step="0.01"
class="prev-input eg-input cat-input-<?= $safeOwnerCls ?>-<?= $month ?>"
value="<?= $amount != 0 ? round($amount) : '' ?>"
placeholder="-"
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<div class="eg-row eg-extres-row">
<div class="eg-cell eg-sticky"><strong><?= tr('bud_sav_extra') ?></strong></div>
<?php foreach ($months as $month):
$total = $data[$month]['TOTAL_BANQUE'] ?? 0;
$sum = 0;
foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0);
$extra = $total - $sum;
?>
<div class="eg-cell font-bold sum-target <?= $extra >= 0 ? 'text-success' : 'text-danger' ?>" id="extra_<?= $safeOwnerCls ?>_<?= $month ?>">
<?= number_format($extra, 0, ',', ' ') ?> €
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div id="savingsModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 600px; width: 95%;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 id="savingsModalTitle" class="pf-modal-title" style="margin:0;"><?= tr('bud_sav_modal_title_add') ?></h3>
<button type="button" onclick="document.getElementById('savingsModal').style.display='none'; document.body.classList.remove('no-scroll');" style="border:none; background:none; font-size:1.8rem; cursor:pointer; color:#64748b; line-height:1;">&times;</button>
<div class="pf-modal-content modal-epargne">
<div class="pf-modal-header">
<h3 id="savingsModalTitle" class="pf-modal-title"><?= tr('bud_sav_modal_title_add') ?></h3>
<button type="button" onclick="document.getElementById('savingsModal').classList.remove('open'); document.body.classList.remove('no-scroll');" class="pf-modal-close">&times;</button>
</div>
<form action="modules/budget/includes/api/save-savings.php" method="POST" id="savingsForm">
<form action="/modules/budget/includes/api/save-savings.php" method="POST" id="savingsForm">
<input type="hidden" name="owner" id="sav_owner">
<input type="hidden" name="redirect_tab" id="redirect_tab" value="<?= htmlspecialchars($requestedOwner) ?>">
<input type="hidden" name="month_date" id="sav_date_hidden">
<div style="display:flex; gap:15px; margin-bottom:20px;">
<div class="form-group" style="flex:1; margin:0;">
<div class="pf-form-row">
<div class="pf-form-group pf-flex-1 pf-m-0">
<label class="pf-label"><?= tr('bud_sav_month_concerned') ?></label>
<input type="month" id="sav_month" required class="pf-input">
</div>
<div class="form-group" style="flex:1; margin:0;">
<div class="pf-form-group pf-flex-1 pf-m-0">
<label class="pf-label"><?= tr('bud_sav_total_bank_eur') ?></label>
<input type="number" step="0.01" name="values[TOTAL_BANQUE]" id="sav_total" required class="pf-input no-spinners" style="font-weight:bold; color:#2563eb;">
<input type="number" step="0.01" name="values[TOTAL_BANQUE]" id="sav_total" required class="pf-input no-spinners pf-input-total">
</div>
</div>
<div class="separator" style="margin: 20px 0; border-bottom: 1px solid #e2e8f0;"></div>
<hr class="pf-divider">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
<div class="ventilation-header">
<div>
<h4 style="margin:0; font-size:1rem; color:#1e293b;"><?= tr('bud_sav_ventilation') ?></h4>
<span style="font-size:0.8rem; color:#64748b;"><?= tr('bud_sav_adj_help') ?></span>
<h4 class="pf-m-0 text-main"><?= tr('bud_sav_ventilation') ?></h4>
<span class="pf-muted-note"><?= tr('bud_sav_adj_help') ?></span>
</div>
<button type="button" class="pf-btn btn-secondary" onclick="addCustomEpargneLine()" style="padding:4px 10px; height:auto; width:auto; font-size:0.9rem;">+ <?= tr('bud_sav_add_line') ?></button>
<button type="button" class="pf-btn btn-secondary pf-btn-sm" onclick="addCustomEpargneLine()">+ <?= tr('bud_sav_add_line') ?></button>
</div>
<div style="display:flex; gap:10px; padding:0 5px 5px 5px; font-size:0.8rem; color:#64748b; font-weight:600;">
<div style="flex:2;"><?= tr('bud_category') ?></div>
<div style="width:100px;"><?= tr('bud_sav_current') ?></div>
<div style="width:90px;"><?= tr('bud_sav_adjust') ?></div>
<div style="width:100px;"><?= tr('bud_sav_new') ?></div>
<div style="width:28px;"></div>
<div class="ventilation-cols">
<div class="vl-col-name"><?= tr('bud_category') ?></div>
<div class="vl-col-base"><?= tr('bud_sav_current') ?></div>
<div class="vl-col-adj"><?= tr('bud_sav_adjust') ?></div>
<div class="vl-col-final"><?= tr('bud_sav_new') ?></div>
<div class="vl-col-btn"></div>
</div>
<div id="linesContainer" style="max-height: 350px; overflow-y: auto; padding-right:5px; display:flex; flex-direction:column; gap:10px;">
</div>
<div id="linesContainer" class="ventilation-list"></div>
<div style="margin-top:25px; display:flex; justify-content:flex-end; gap:10px;">
<button type="button" onclick="document.getElementById('savingsModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary" style="width:auto; margin:0;"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn" style="width:auto; margin:0;"><?= tr('btn_save') ?></button>
<div class="modal-footer">
<button type="button" onclick="document.getElementById('savingsModal').classList.remove('open'); 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>
</form>
</div>
@@ -272,7 +264,6 @@ function getMonthName($dateString) {
</div>
<script>
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
@@ -281,19 +272,47 @@ window.I18N = {
'bud_sav_ph_name': <?= json_encode(tr('bud_sav_ph_name')) ?>,
'bud_sav_confirm_delete_month': <?= json_encode(tr('bud_sav_confirm_delete_month')) ?>,
'bud_sav_prompt_duplicate': <?= json_encode(tr('bud_sav_prompt_duplicate')) ?>,
'bud_sav_prompt_new_line': <?= json_encode(tr('bud_sav_prompt_new_line')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>,
'bud_err_network_dup': <?= json_encode(tr('bud_err_network_dup')) ?>,
'bud_sav_saving': <?= json_encode(tr('bud_sav_saving')) ?>,
'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?>
'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?>,
'btn_delete': <?= json_encode(tr('btn_delete')) ?>
};
// Sécurisation de la devise pour la calculatrice
const systemCurrency = (typeof window.CONFIG !== 'undefined' && window.CONFIG.CURRENCY) ? window.CONFIG.CURRENCY : '€';
// --- 2. GESTION DE L'ÉDITION INVISIBLE EN DIRECT (CORRIGÉE 🚀) ---
const cycleConfigs = <?= json_encode($cycleConfigs ?? []) ?>;
async function promptNewSavingsLine(owner, latestMonth) {
const catName = prompt(window.I18N['bud_sav_prompt_new_line']);
if (!catName || catName.trim() === "") return;
const formData = new FormData();
formData.append('action', 'update_single_entry');
formData.append('month_date', latestMonth);
formData.append('category', catName.trim());
formData.append('owner', owner);
formData.append('amount', 0);
formData.append('force_insert', '1'); // 🛡️ Empêche la suppression auto par l'API
formData.append('ajax', '1');
try {
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', {
method: 'POST',
body: formData
});
if (result.success) {
window.location.reload();
} else {
showToast(window.I18N['bud_err_tech'] + " : " + (result.error || "Erreur"), 'error');
}
} catch (err) {
showToast(window.I18N['bud_err_tech'], 'error');
}
}
async function updateEpargneCell(month, category, owner, inputEl) {
const val = parseFloat(inputEl.value) || 0;
const formData = new FormData();
@@ -302,27 +321,19 @@ async function updateEpargneCell(month, category, owner, inputEl) {
formData.append('category', category);
formData.append('owner', owner);
formData.append('amount', val);
formData.append('ajax', '1'); // 🚀 CRITIQUE : Informe l'API
formData.append('ajax', '1');
try {
// 🚀 CRITIQUE : pachaFetch + Chemin absolu
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', {
method: 'POST',
body: formData
});
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: 'POST', body: formData });
if (!result.success) {
console.error("Erreur de sauvegarde :", result.error);
alert(window.I18N['bud_err_tech'] + " : " + (result.error || "Erreur serveur"));
return; // On stoppe la mise à jour visuelle si la BDD a refusé
return;
}
} catch (err) {
console.error("Erreur réseau :", err);
alert(window.I18N['bud_err_tech'] || 'Erreur technique');
showToast(window.I18N['bud_err_tech'], 'error');
return;
}
// Si on arrive ici, la BDD est bien à jour ! On met à jour le visuel.
const safeOwnerClass = owner.replace(/\s+/g, '_');
const totalInput = document.querySelector(`.total-input-${CSS.escape(safeOwnerClass)}-${month}`);
const totalVal = parseFloat(totalInput ? totalInput.value : 0) || 0;
@@ -337,7 +348,13 @@ async function updateEpargneCell(month, category, owner, inputEl) {
if (extraCell) {
extraCell.innerText = Math.round(extra).toLocaleString(window.appLang) + ' €';
extraCell.style.color = extra >= 0 ? '#10b981' : '#ef4444';
if (extra >= 0) {
extraCell.classList.remove('text-danger');
extraCell.classList.add('text-success');
} else {
extraCell.classList.remove('text-success');
extraCell.classList.add('text-danger');
}
}
if(isSumModeActive) updateSumResult();
@@ -349,20 +366,20 @@ function addCustomEpargneLine(catName = '', amount = '') {
const inputName = catName ? `values[${catName}]` : '';
const html = `
<div class="ventilation-line" style="display:flex; gap:10px; align-items:center; background:#f8fafc; padding:8px; border-radius:8px; border:1px solid #e2e8f0;">
<div style="flex:2;">
<input type="text" class="pf-input cat-name-input" value="${catName}" placeholder="${window.I18N['bud_sav_ph_name'] || 'Catégorie'}" oninput="updateCustomFieldName(this)" style="padding:6px; font-size:0.9rem;" required>
<div class="ventilation-line">
<div class="vl-col-name">
<input type="text" class="pf-input cat-name-input pf-input-sm" value="${catName}" placeholder="${window.I18N['bud_sav_ph_name'] || 'Catégorie'}" oninput="updateCustomFieldName(this)" required>
</div>
<div style="width:100px;">
<input type="number" step="0.01" class="pf-input base-amount no-spinners" value="${baseAmount}" oninput="recalculateCustomLine(this)" style="padding:6px; font-size:0.9rem; background:#fff;">
<div class="vl-col-base">
<input type="number" step="0.01" class="pf-input base-amount no-spinners pf-input-sm bg-white" value="${baseAmount}" oninput="recalculateCustomLine(this)">
</div>
<div style="width:90px;">
<input type="number" step="0.01" class="pf-input adjustment-amount no-spinners" placeholder="+ / -" oninput="recalculateCustomLine(this)" style="padding:6px; font-size:0.9rem; color:#f59e0b; font-weight:bold;">
<div class="vl-col-adj">
<input type="number" step="0.01" class="pf-input adjustment-amount no-spinners pf-input-sm vl-input-adj" placeholder="+ / -" oninput="recalculateCustomLine(this)">
</div>
<div style="width:100px;">
<input type="number" step="0.01" name="${inputName}" class="pf-input final-amount no-spinners" value="${baseAmount}" style="padding:6px; font-size:0.9rem; font-weight:bold; background:#e0f2fe; border-color:#bae6fd; color:#0369a1;" readonly>
<div class="vl-col-final">
<input type="number" step="0.01" name="${inputName}" class="pf-input final-amount no-spinners pf-input-sm vl-input-final" value="${baseAmount}" readonly>
</div>
<button type="button" onclick="this.parentElement.remove()" style="width:28px; height:28px; border:none; background:#fee2e2; color:#ef4444; border-radius:4px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-weight:bold;">&times;</button>
<button type="button" class="btn-remove-line" onclick="this.parentElement.remove()" title="${window.I18N['btn_delete']}">&times;</button>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
@@ -408,7 +425,7 @@ function editCustomSavingsMonth(monthDate, owner, rowData) {
if (container.children.length === 0) addCustomEpargneLine();
document.getElementById('savingsModal').style.display = 'flex';
document.getElementById('savingsModal').classList.add('open');
document.body.classList.add('no-scroll');
}
@@ -423,18 +440,10 @@ function openCustomSavingsModal(owner) {
container.innerHTML = '';
addCustomEpargneLine();
document.getElementById('savingsModal').style.display = 'flex';
document.getElementById('savingsModal').classList.add('open');
document.body.classList.add('no-scroll');
}
window.onclick = function(event) {
const modal = document.getElementById('savingsModal');
if (event.target == modal) {
modal.style.display = 'none';
document.body.classList.remove('no-scroll');
}
}
const savingsForm = document.getElementById('savingsForm');
if (savingsForm) {
savingsForm.addEventListener('submit', async function(e) {
@@ -451,25 +460,19 @@ if (savingsForm) {
submitBtn.disabled = true;
const formData = new FormData(this);
formData.append('ajax', '1'); // 🚀 CRITIQUE
formData.append('ajax', '1');
try {
// 🚀 CRITIQUE : pachaFetch + Chemin absolu forcé
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', {
method: 'POST',
body: formData
});
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: 'POST', body: formData });
if (result.success) {
window.location.reload(); // Ne recharge QUE si le serveur a dit OK
window.location.reload();
} else {
alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (result.error || "Inconnue"));
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
} catch (error) {
console.error("Erreur catch:", error);
alert(window.I18N['bud_err_tech'] || 'Erreur Technique');
showToast(window.I18N['bud_err_tech'], 'error');
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
@@ -477,7 +480,7 @@ if (savingsForm) {
}
async function deleteEntireMonth(monthDate, owner) {
const rawMsg = window.I18N['bud_sav_confirm_delete_month'] || "Supprimer %m pour %o ?";
const rawMsg = window.I18N['bud_sav_confirm_delete_month'];
const msg = rawMsg.replace('%m', monthDate).replace('%o', owner);
if (!confirm(msg)) return;
@@ -485,14 +488,14 @@ async function deleteEntireMonth(monthDate, owner) {
formData.append("action", "delete_month_global");
formData.append("month_date", monthDate);
formData.append("owner", owner);
formData.append("ajax", "1"); // 🚀 CRITIQUE
formData.append("ajax", "1");
try {
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: "POST", body: formData });
if (result.success) window.location.reload();
else alert(result.error || window.I18N['bud_err_delete']);
} catch(err) {
alert(window.I18N['bud_err_delete'] || 'Erreur lors de la suppression');
showToast(window.I18N['bud_err_tech'], 'error');
}
}
@@ -513,7 +516,7 @@ async function duplicateLastMonth(lastMonthDate, owner) {
defaultTotal = cycleConfigs[nextMonthStr].start_balance;
}
const rawMsg = window.I18N['bud_sav_prompt_duplicate'] || "Dupliquer vers le mois suivant ?";
const rawMsg = window.I18N['bud_sav_prompt_duplicate'];
const message = rawMsg.replace('%s', formatMonth(lastMonthDate)).replace(/%t[12]/g, formatMonth(nextMonthStr));
let newTotal = prompt(message, defaultTotal);
@@ -525,7 +528,7 @@ async function duplicateLastMonth(lastMonthDate, owner) {
formData.append("target_date", nextMonthStr);
formData.append("new_total", newTotal);
formData.append("owner", owner);
formData.append("ajax", "1"); // 🚀 CRITIQUE
formData.append("ajax", "1");
try {
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: "POST", body: formData });
@@ -535,8 +538,7 @@ async function duplicateLastMonth(lastMonthDate, owner) {
alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (result.error || "Inconnue"));
}
} catch(err) {
console.error(err);
alert(window.I18N['bud_err_network_dup'] || 'Erreur réseau.');
alert(window.I18N['bud_err_network_dup']);
}
}
}
+265
View File
@@ -0,0 +1,265 @@
<?php
// modules/budget/views/provisions.php
// --- RÉCUPÉRATION DES DÉPENSES PRÉVUES (MULTI-TENANT PDO) ---
$stmt = $pdo->query("SELECT id, title, amount, expected_date FROM pf_expected_expenses WHERE is_paid = 0 ORDER BY expected_date ASC");
$provisions = $stmt->fetchAll(PDO::FETCH_ASSOC);
// --- RÉCUPÉRATION DES APPORTS ET DETTES (CLEARING) ---
$currentYear = date('Y');
// 1. Apports de base (Eco Family) des parents configurés pour l'année
$stmtConfig = $pdo->prepare("SELECT person, eco_family FROM pf_salary_config WHERE year = ?");
$stmtConfig->execute([$currentYear]);
$configs = $stmtConfig->fetchAll(PDO::FETCH_KEY_PAIR);
// 2. Dettes non résolues par personne (issues des Avances & Tricount)
$stmtDebts = $pdo->query("SELECT payer, SUM(amount) as total_debt FROM pf_advances WHERE is_resolved = 0 GROUP BY payer");
$debts = $stmtDebts->fetchAll(PDO::FETCH_KEY_PAIR);
function formatDate($dateString) {
if (!$dateString) return '';
return date('d/m/Y', strtotime($dateString));
}
?>
<div class="budget-view">
<div class="view-header">
<h2><?= tr('budget_provisions_title') ?></h2>
<button class="pf-btn pf-btn-primary" onclick="openOptimizeModal()">
✨ <?= tr('btn_optimize_cashflow') ?>
</button>
</div>
<div class="pf-card">
<h3 class="pf-card-h2"><?= tr('add_new_provision') ?></h3>
<form id="form-add-provision" data-action="/modules/budget/includes/api/add-provision.php">
<div class="provisions-form-grid">
<div class="pf-form-group">
<label class="pf-label" for="prov-title"><?= tr('provision_label') ?></label>
<input type="text" id="prov-title" name="title" class="pf-input" placeholder="<?= tr('provision_placeholder_wood') ?>" required>
</div>
<div class="pf-form-group">
<label class="pf-label" for="prov-amount"><?= tr('amount') ?> (€)</label>
<input type="number" id="prov-amount" name="amount" class="pf-input no-spinners input-amount-highlight" step="0.01" min="0.01" required>
</div>
<div class="pf-form-group">
<label class="pf-label" for="prov-date"><?= tr('expected_date') ?></label>
<input type="date" id="prov-date" name="expected_date" class="pf-input" required>
</div>
<button type="submit" class="pf-btn pf-btn-secondary">
<?= tr('btn_add') ?>
</button>
</div>
</form>
</div>
<div class="budget-table-card table-responsive">
<?php if (empty($provisions)): ?>
<div class="optimization-hint" style="padding: 30px;">
<p><?= tr('no_provisions') ?></p>
</div>
<?php else: ?>
<table class="pf-table savings-table provisions-table">
<thead>
<tr>
<th><?= tr('provision_label') ?></th>
<th style="width: 150px;"><?= tr('amount') ?></th>
<th style="width: 150px;"><?= tr('expected_date') ?></th>
<th style="width: 100px;"><?= tr('actions') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($provisions as $p): ?>
<tr>
<td class="prov-title-cell">
<?= htmlspecialchars($p['title']) ?>
</td>
<td class="prov-amount-cell">
<?= number_format($p['amount'], 2, ',', ' ') ?> €
</td>
<td class="prov-date-cell">
<?= formatDate($p['expected_date']) ?>
</td>
<td class="text-right" style="padding-right: 15px;">
<button class="btn-icon-action delete btn-safe-click" title="<?= tr('btn_delete') ?>" onclick="deleteProvision(<?= $p['id'] ?>)">
🗑️
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<div id="optimizeModal" class="pf-modal">
<div class="pf-modal-content provisions-modal-content">
<div class="pf-modal-header">
<h3 class="pf-modal-title">🧠 <?= tr('optimization_assistant_title') ?></h3>
<button type="button" class="pf-modal-close" onclick="closeOptimizeModal()">&times;</button>
</div>
<form id="form-optimize" data-action="/modules/budget/includes/api/optimize-cashflow.php">
<div id="dynamic-savings-inputs">
<?php if (empty($configs)): ?>
<p class="text-danger font-bold pf-muted-tiny"><?= tr('budget_opti_no_config') ?></p>
<input type="number" step="0.01" min="0" name="savings[Global]" class="pf-input no-spinners" required>
<?php else: ?>
<?php foreach ($configs as $person => $ecoBase):
$debt = $debts[$person] ?? 0;
?>
<div class="pf-form-group provision-person-card">
<label class="pf-label provision-person-header">
<strong class="text-main"><?= htmlspecialchars($person) ?></strong>
<span class="pf-muted-note">
<?php if ($debt > 0): ?>
<span class="text-danger font-bold">(Dette en attente : <?= (float)$debt ?> €)</span>
<?php endif; ?>
</span>
</label>
<div class="provision-input-group">
<input type="number" step="0.01" min="0" name="savings[<?= htmlspecialchars($person) ?>]" class="pf-input no-spinners input-amount-success" value="<?= (float)$ecoBase ?>" required>
<span class="currency-symbol">€</span>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<button type="submit" class="pf-btn pf-btn-primary btn-block">
<?= tr('calculate') ?>
</button>
</form>
<hr class="pf-divider">
<div id="optimization-results">
<p class="optimization-hint">
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
</p>
</div>
</div>
</div>
<script>
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
'btn_delete': <?= json_encode(tr('btn_delete')) ?>,
'error_generic': <?= json_encode(tr('error_generic')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>
};
// --- 2. GESTION DU FORMULAIRE D'AJOUT ---
const formAdd = document.getElementById('form-add-provision');
if (formAdd) {
formAdd.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerText;
submitBtn.innerText = '⏳';
submitBtn.disabled = true;
try {
const result = await pachaFetch(this.getAttribute('data-action'), {
method: 'POST',
body: new FormData(this)
});
if (result.success) {
window.location.reload();
} else {
alert(result.message || window.I18N['error_generic']);
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
} catch (error) {
showToast(window.I18N['bud_err_tech'], 'error');
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
});
}
// --- 3. GESTION DE LA SUPPRESSION ---
async function deleteProvision(id) {
if (!confirm(window.I18N['btn_delete'] + ' ?')) return;
try {
const result = await pachaFetch('/modules/budget/includes/api/delete-provision.php', {
method: "POST",
body: new URLSearchParams({ id: id })
});
if (result.success) {
window.location.reload();
} else {
alert(result.message);
}
} catch(err) {
showToast(window.I18N['bud_err_tech'], 'error');
}
}
// --- 4. CONTROL DE LA MODALE D'OPTIMISATION ---
function openOptimizeModal() {
document.getElementById('optimization-results').innerHTML = `
<p class="optimization-hint">
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
</p>`;
document.getElementById('optimizeModal').classList.add('open');
document.body.classList.add('no-scroll');
}
function closeOptimizeModal() {
document.getElementById('optimizeModal').classList.remove('open');
document.body.classList.remove('no-scroll');
}
window.onclick = function(event) {
const modal = document.getElementById('optimizeModal');
if (event.target == modal) {
closeOptimizeModal();
}
}
// --- 5. EXECUTION ET SUBMIT DE L'ALGORITHME (formOptimize) ---
const formOptimize = document.getElementById('form-optimize');
if (formOptimize) {
formOptimize.addEventListener('submit', async function(e) {
e.preventDefault();
const resultsContainer = document.getElementById('optimization-results');
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.disabled = true;
resultsContainer.innerHTML = '<div class="optimization-hint">⏳ Analyse des quinzaines bancaires...</div>';
try {
const result = await pachaFetch(this.getAttribute('data-action'), {
method: 'POST',
body: new FormData(this)
});
if (result.success) {
resultsContainer.innerHTML = result.html;
} else {
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${result.message}</p>`;
}
} catch (error) {
console.error("Erreur d'optimisation :", error);
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${window.I18N['bud_err_tech']}</p>`;
} finally {
submitBtn.disabled = false;
}
});
}
</script>
+21
View File
@@ -128,6 +128,23 @@ $totalRevenus = 0;
foreach ($items as $item):
// 1. Calculs de base
$targetAbs = abs((float)$item['amount']);
// --- 🚀 INTERCEPTION DYNAMIQUE ---
$calcDetails = "";
if (isset($item['is_dynamic']) && $item['is_dynamic'] == 1 && !empty($item['dynamic_code'])) {
require_once __DIR__ . '/../includes/DynamicBudgetCalculator.php';
// Facturation à terme échu : on recule d'un mois pour le calcul
$prevDate = date('Y-m-d', strtotime('-1 month', strtotime("$currentYear-$currentMonth-01")));
$calcYear = date('Y', strtotime($prevDate));
$calcMonth = date('m', strtotime($prevDate));
$calc = new DynamicBudgetCalculator($pdo, $calcYear, $calcMonth);
$estimateData = $calc->getEstimate($item['dynamic_code']);
$targetAbs = $estimateData['amount'];
$calcDetails = $estimateData['details'];
}
// --------------------------------
$amountToAdd = ($item['type'] === 'Annuel') ? $targetAbs / 12 : $targetAbs;
$totalDepenses += $amountToAdd;
@@ -185,6 +202,10 @@ foreach ($items as $item):
<td class="cell-amount" style="font-weight:600; padding:15px; color:#1e293b;">
- <?= number_format($targetAbs, 2, ',', ' ') ?> €
<?php if (!empty($calcDetails)): ?>
<span title="<?= htmlspecialchars($calcDetails) ?>" style="cursor:help; margin-left:4px; font-size:0.85rem;">ℹ️</span>
<?php endif; ?>
<?php if ($hasMatchingExpense): ?>
<?php $gap = $realAbs - $targetAbs;
if ($gap > 0.05): ?>
+167 -30
View File
@@ -1,4 +1,12 @@
<?php
/**
* @var bool $hasAccounts
* @var bool $hasCategories
* @var bool $hasSalaries
* @var bool $hasBudgetItems
* @var bool $isBudgetSetupOk
*/
// Sécurité : Ce fichier ne doit pas être appelé directement
if (!defined('CURRENCY')) {
define('CURRENCY', '€');
@@ -26,6 +34,7 @@ if (!defined('CURRENCY')) {
<?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>
<button class="bs-tab-btn" onclick="switchBsTab('children', this)">👶 <?= tr('bud_settings_dyn_title') ?></button>
</div>
<div class="bs-content">
@@ -80,6 +89,9 @@ if (!defined('CURRENCY')) {
<option value="checking"><?= tr('bs_type_checking') ?></option>
<option value="savings"><?= tr('bs_type_savings') ?></option>
</select>
<select name="owner_person_id" id="select-account-owner" class="pf-input bs-input-fixed">
<option value="">Commun</option>
</select>
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
@@ -209,11 +221,60 @@ if (!defined('CURRENCY')) {
</form>
</div>
<!-- NOUVEAU PANE ENFANTS -->
<div id="pane-children" class="bs-pane">
<h4 class="bs-section-title">👶 <?= tr('bud_settings_dyn_title') ?></h4>
<p class="pf-muted-tiny bs-desc">Configurez ici les tarifs pour le calcul dynamique des frais de garde et de cantine.</p>
<form id="form-dynamic-estimates" onsubmit="saveDynamicEstimates(event)">
<h5 class="bs-section-subtitle bordered">Assistante Maternelle</h5>
<div class="bs-grid-2">
<div>
<label class="pf-label"><?= tr('bud_dyn_nanny_fixed') ?></label>
<input type="number" step="0.01" name="budget_nanny_fixed" id="budget_nanny_fixed" class="pf-input" value="700">
</div>
<div>
<label class="pf-label"><?= tr('bud_dyn_nanny_daily') ?></label>
<input type="number" step="0.01" name="budget_nanny_daily" id="budget_nanny_daily" class="pf-input" value="4">
</div>
<div>
<label class="pf-label"><?= tr('bud_dyn_nanny_aid') ?></label>
<input type="number" step="0.01" name="budget_nanny_aid" id="budget_nanny_aid" class="pf-input" value="120">
</div>
<div>
<label class="pf-label"><?= tr('bud_dyn_nanny_cesu_avg') ?></label>
<input type="number" step="0.01" name="budget_nanny_cesu_avg" id="budget_nanny_cesu_avg" class="pf-input" value="170">
</div>
</div>
<h5 class="bs-section-subtitle bordered" style="margin-top: 20px;">École & Centre de loisirs</h5>
<div class="bs-grid-2">
<div>
<label class="pf-label"><?= tr('bud_dyn_school_meal') ?></label>
<input type="number" step="0.01" name="budget_school_meal" id="budget_school_meal" class="pf-input" value="5.62">
</div>
<div>
<label class="pf-label"><?= tr('bud_dyn_school_aftercare') ?></label>
<input type="number" step="0.01" name="budget_school_aftercare" id="budget_school_aftercare" class="pf-input" value="1.97">
</div>
<div>
<label class="pf-label"><?= tr('bud_dyn_school_fullday') ?></label>
<input type="number" step="0.01" name="budget_school_fullday" id="budget_school_fullday" class="pf-input" value="21.89">
</div>
</div>
<div class="bs-text-right" style="margin-top: 20px;">
<button type="submit" class="btn btn-secondary">💾 <?= tr('btn_save') ?></button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tabsContainer = document.querySelector('.budget-tabs-container');
@@ -250,16 +311,36 @@ function switchBsTab(tabId, btnEl) {
}
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);
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'));
// Alimenter la liste des propriétaires pour les comptes
const ownerSelect = document.getElementById('select-account-owner');
if (ownerSelect && response.data.people) {
ownerSelect.innerHTML = '<option value="">Commun</option>';
response.data.people.forEach(p => {
ownerSelect.innerHTML += `<option value="${p.id}">${p.name}</option>`;
});
}
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;
const bs = response.data.budget_settings || {};
if (document.getElementById('budget_nanny_fixed')) document.getElementById('budget_nanny_fixed').value = bs.budget_nanny_fixed || 700;
if (document.getElementById('budget_nanny_daily')) document.getElementById('budget_nanny_daily').value = bs.budget_nanny_daily || 4;
if (document.getElementById('budget_nanny_aid')) document.getElementById('budget_nanny_aid').value = bs.budget_nanny_aid || 120;
if (document.getElementById('budget_nanny_cesu_avg')) document.getElementById('budget_nanny_cesu_avg').value = bs.budget_nanny_cesu_avg || 170;
if (document.getElementById('budget_school_meal')) document.getElementById('budget_school_meal').value = bs.budget_school_meal || 5.62;
if (document.getElementById('budget_school_aftercare')) document.getElementById('budget_school_aftercare').value = bs.budget_school_aftercare || 1.97;
if (document.getElementById('budget_school_fullday')) document.getElementById('budget_school_fullday').value = bs.budget_school_fullday || 21.89;
} 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>`;
@@ -324,7 +405,7 @@ function renderCategories(categories) {
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action" onclick="moveCatUp(this)" title="Monter">⬆️</button>
<button type="button" class="btn-icon-action" onclick="moveCatDown(this)" title="Descendre">⬇️</button>
<button type="button" class="btn-icon-action delete" onclick="deleteCategory(${cat.id})" title="${tr('btn_delete')}">🗑️</button>
<button type="button" class="btn-icon-action delete" onclick="deleteBudgetCategory(${cat.id})" title="${tr('btn_delete')}">🗑️</button>
</div>
</div>`;
});
@@ -449,18 +530,62 @@ function renderSalaries(salaries, year) {
}
// --- 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); } }
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) { if (typeof showToast === 'function') showToast(err.message, 'error'); else 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; } });
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(); if (typeof showToast === 'function') showToast("Compte ajouté", 'success'); } catch (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); else alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
async function deleteBudgetCategory(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);
const res = await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd });
if (!res.success) throw new Error(res.error || tr('error_occured'));
await loadBudgetSettingsData();
} catch (err) {
if (typeof showToast === 'function') {
showToast(err.message, 'error');
} else {
alert("Erreur : " + err.message);
}
}
}
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; } });
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');
const res = await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd });
if (!res.success) throw new Error(res.error || "Erreur lors de l'ajout");
formAddCategory.reset();
await loadBudgetSettingsData();
if (typeof showToast === 'function') showToast("Catégorie ajoutée", "success");
} catch (err) {
if (typeof showToast === 'function') showToast(err.message, 'error'); else 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); } }
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) { if (typeof showToast === 'function') showToast(err.message, 'error'); else 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; } });
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(); if (typeof showToast === 'function') showToast("Règle ajoutée", 'success'); } catch (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); else 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 ?>';
@@ -473,14 +598,16 @@ function inlineEditSalary(btnEl) {
<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); } }
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(); if (typeof showToast === 'function') showToast("Salaire mis à jour", 'success'); } catch (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); else 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')} ✅`, 'success'); } await loadBudgetSettingsData(); } catch (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); else 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; } }
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'), 'success'); else alert(tr('bs_csv_saved')); } catch (err) { if (typeof showToast === 'function') showToast(tr('error_occured') + " : " + err.message, 'error'); else 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) {
@@ -490,9 +617,13 @@ if (dropZone && fileInput) {
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}`); }
if (!file.name.endsWith('.csv')) {
if (typeof showToast === 'function') showToast(tr('bs_csv_err_type'), 'error'); else alert(tr('bs_csv_err_type'));
return;
}
if (typeof showToast === 'function') { showToast(`${tr('bs_csv_file_ok')} : ${file.name}`, 'success'); }
const reader = new FileReader();
reader.onload = function(e) {
const text = e.target.result; const delimiter = document.getElementById('csv_delimiter').value || ';';
@@ -506,18 +637,24 @@ function handleCsvUpload(file) {
};
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';
async function saveDynamicEstimates(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_dynamic_estimates');
await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd });
if (typeof showToast === 'function') showToast(window.I18N['fc_settings_updated'] || 'Paramètres enregistrés', 'success');
} catch (err) {
if (typeof showToast === 'function') showToast((window.I18N['error_occured'] || 'Erreur') + " : " + err.message, 'error');
} finally {
btn.innerText = oldText;
btn.disabled = false;
}
}
</script>
+168 -148
View File
@@ -287,13 +287,30 @@ while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
// 2. C'est une estimation (Variable)
if ((int)$item['is_estimate'] === 1) {
$calcDetails = "";
// --- 🚀 INTERCEPTION DYNAMIQUE ---
if (isset($item['is_dynamic']) && $item['is_dynamic'] == 1 && !empty($item['dynamic_code'])) {
require_once __DIR__ . '/../includes/DynamicBudgetCalculator.php';
// Facturation à terme échu : on recule d'un mois
$prevDate = date('Y-m-d', strtotime('-1 month', strtotime("$viewY-$viewM-01")));
$calcYear = date('Y', strtotime($prevDate));
$calcMonth = date('m', strtotime($prevDate));
$calc = new DynamicBudgetCalculator($pdo, $calcYear, $calcMonth);
$estimateData = $calc->getEstimate($item['dynamic_code']);
$amt = $estimateData['amount'];
$calcDetails = $estimateData['details'];
}
// --------------------------------
$estimatesList[] = [
'name' => $name,
'amount' => $amt,
'categories' => !empty($catCode) ? array_map('trim', explode(',', $catCode)) : []
'categories' => !empty($catCode) ? array_map('trim', explode(',', $catCode)) : [],
'details' => $calcDetails
];
}
// 3. Attribution du budget aux jauges visuelles de la page
// Pour ne pas tout casser, on donne tout le plafond visuel à la 1ère catégorie de la liste
if (!empty($catCode)) {
@@ -352,11 +369,9 @@ foreach ($estimatesList as $est) {
$spentForEstimate = 0;
$detailsHover = [];
// On additionne les dépenses de toutes les catégories liées à cette estimation
foreach ($est['categories'] as $cCode) {
if (!empty($cCode) && isset($totals[$cCode])) {
$spentForEstimate += $totals[$cCode];
// On prépare le détail pour la petite bulle d'info (hover)
if ($totals[$cCode] > 0 && isset($categoriesConfig[$cCode])) {
$detailsHover[] = strip_tags($categoriesConfig[$cCode]['label']) . " : " . number_format($totals[$cCode], 0) . "€";
}
@@ -367,6 +382,12 @@ foreach ($estimatesList as $est) {
if ($rem > 0) {
$reste_a_venir_calc += $rem;
$tooltip = !empty($detailsHover) ? implode(' | ', $detailsHover) : 'Aucune dépense pour le moment';
// --- INJECTION DU DÉTAIL DYNAMIQUE ---
if (!empty($est['details'])) {
$tooltip .= "\n\n📊 Détail du calcul :\n" . $est['details'];
}
$pending_charges[] = [
'name' => 'Reste ' . $est['name'],
'amount' => $rem,
@@ -425,90 +446,90 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<div class="budget-view">
<div style="background:white; padding:20px; border-radius:16px; box-shadow:var(--shadow-sm); margin-bottom:24px; border:1px solid #e2e8f0; position:relative;">
<div class="bud-summary-card">
<?php if ($isClosed): ?>
<div style="position:absolute; top:0; left:0; right:0; background:#f1f5f9; padding:5px; text-align:center; font-size:0.8rem; font-weight:bold; color:#64748b; border-radius:16px 16px 0 0; border-bottom:1px solid #e2e8f0;">
<div class="bud-closed-banner">
🔒 <?= tr('bud_closed_archived') ?>
</div>
<div style="height:20px;"></div>
<?php endif; ?>
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px; margin-bottom: 20px;">
<div style="display:flex; align-items:center; gap:15px;">
<h2 style="margin:0; font-size:1.3rem; color:#0f172a; text-transform:capitalize;"><?= tr('bud_budget_of') ?> <?= $monthName ?></h2>
<div class="bud-header-group">
<div class="bud-header-title-wrapper">
<h2 class="bud-header-title"><?= tr('bud_budget_of') ?> <?= $monthName ?></h2>
</div>
<div class="action-toolbar" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<?php if (!$isClosed): ?>
<button type="button" class="pf-btn btn-secondary" onclick="openSuiviModal('importCsvModal')" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">
📂 <?= tr('bud_import_csv') ?? 'Importer CSV' ?>
</button>
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_close') ?>');">
<input type="hidden" name="action" value="close_month">
<input type="hidden" name="close_month_date" value="<?= $viewMonthDate ?>">
<input type="hidden" name="freeze_solde_actuel" value="<?= $solde_actuel ?>">
<input type="hidden" name="freeze_capacite_max" value="<?= $capacite_max ?>">
<input type="hidden" name="freeze_solde_theorique" value="<?= $solde_theorique ?>">
<input type="hidden" name="freeze_reste_a_venir" value="<?= $reste_a_venir ?>">
<button type="submit" class="pf-btn" style="background:linear-gradient(135deg, #10b981, #059669); padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔒 <?= tr('bud_close_btn') ?></button>
</form>
<?php else: ?>
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_reopen') ?>');">
<input type="hidden" name="action" value="reopen_month">
<button type="submit" class="pf-btn btn-secondary" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔓 <?= tr('bud_reopen_btn') ?></button>
</form>
<?php endif; ?>
<div class="suivi-nav-group">
<a href="<?= $prevLink ?>" class="suivi-btn-nav">◀</a>
<a href="<?= $defaultLink ?>" class="suivi-btn-nav"><?= tr('bud_active_month_btn') ?></a>
<a href="<?= $nextLink ?>" class="suivi-btn-nav">▶</a>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:15px; margin-bottom:25px;">
<div style="padding:15px; background:#f8fafc; border-radius:12px; position:relative; border:1px solid #e2e8f0;">
<div style="font-size:0.85rem; color:#64748b; margin-bottom:4px;"><?= tr('bud_bank_balance') ?> <?= $isClosed ? "(".tr('bud_frozen').")" : "(".tr('bud_current').")" ?></div>
<div style="font-size:1.4rem; font-weight:700; color:#0f172a;">
<?= number_format($solde_actuel, 2, ',', ' ') ?> €
</div>
<div class="action-toolbar bud-toolbar">
<?php if (!$isClosed): ?>
<button onclick="openSuiviModal('snapshotModal')" style="position:absolute; top:12px; right:12px; background:white; border:1px solid #cbd5e1; border-radius:6px; padding:4px 8px; cursor:pointer; font-size:0.8rem; color:#475569; transition:0.2s;" onmouseover="this.style.background='#f1f5f9'" onmouseout="this.style.background='white'">✏️ <?= tr('bud_update_btn') ?></button>
<?php endif; ?>
</div>
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="openSuiviModal('importCsvModal')">
📂 <?= tr('bud_import_csv') ?? 'Importer CSV' ?>
</button>
<div style="padding:15px; background:#f8fafc; border-radius:12px; border:1px solid #e2e8f0;">
<div style="font-size:0.85rem; color:#64748b; margin-bottom:4px;"><?= tr('bud_theoretical_balance') ?></div>
<div style="font-size:1.4rem; font-weight:700; color:<?= $solde_theorique < 0 ? '#ef4444' : '#334155' ?>;">
<?= number_format($solde_theorique, 2, ',', ' ') ?> €
</div>
</div>
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_close') ?>');">
<input type="hidden" name="action" value="close_month">
<input type="hidden" name="close_month_date" value="<?= $viewMonthDate ?>">
<input type="hidden" name="freeze_solde_actuel" value="<?= $solde_actuel ?>">
<input type="hidden" name="freeze_capacite_max" value="<?= $capacite_max ?>">
<input type="hidden" name="freeze_solde_theorique" value="<?= $solde_theorique ?>">
<input type="hidden" name="freeze_reste_a_venir" value="<?= $reste_a_venir ?>">
<button type="submit" class="pf-btn bud-btn-lock">🔒 <?= tr('bud_close_btn') ?></button>
</form>
<?php else: ?>
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_reopen') ?>');">
<input type="hidden" name="action" value="reopen_month">
<button type="submit" class="pf-btn btn-secondary pf-btn-small">🔓 <?= tr('bud_reopen_btn') ?></button>
</form>
<?php endif; ?>
<div style="padding:15px; background:#fef2f2; border-radius:12px; border:1px solid #fecaca; position: relative;">
<div style="font-size:0.85rem; color:#991b1b; margin-bottom:4px; display:flex; justify-content:space-between; align-items:center;">
<span><?= tr('bud_upcoming_charges') ?></span>
<button onclick="toggleDiv('pendingDetailsList')" style="background:none; border:none; cursor:pointer; font-size:1rem; padding:0; filter: grayscale(1); opacity: 0.7; transition: 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.7">👁️</button>
</div>
<div style="font-size:1.4rem; font-weight:700; color:#b91c1c;">
- <?= number_format($reste_a_venir, 2, ',', ' ') ?> €
</div>
<div id="pendingDetailsList" style="display:none; margin-top: 12px; padding-top: 12px; border-top: 1px dashed #fca5a5; font-size: 0.8rem; color: #7f1d1d;">
<?php if (empty($pending_charges)): ?>
<div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div>
<?php else: ?>
<?php foreach($pending_charges as $pc): ?>
<div style="display:flex; justify-content:space-between; margin-bottom:6px;" <?= !empty($pc['tooltip']) ? 'title="'.htmlspecialchars($pc['tooltip']).'" style="cursor:help;"' : '' ?>>
<span><?= htmlspecialchars($pc['name']) ?></span>
<strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="suivi-nav-group">
<a href="<?= $prevLink ?>" class="suivi-btn-nav">◀</a>
<a href="<?= $defaultLink ?>" class="suivi-btn-nav"><?= tr('bud_active_month_btn') ?></a>
<a href="<?= $nextLink ?>" class="suivi-btn-nav">▶</a>
</div>
</div>
</div>
<div class="bud-kpi-grid">
<div class="bud-kpi-card">
<div class="bud-kpi-label"><?= tr('bud_bank_balance') ?> <?= $isClosed ? "(".tr('bud_frozen').")" : "(".tr('bud_current').")" ?></div>
<div class="bud-kpi-value">
<?= number_format($solde_actuel, 2, ',', ' ') ?> €
</div>
<?php if (!$isClosed): ?>
<button onclick="openSuiviModal('snapshotModal')" class="bud-kpi-edit-btn">✏️ <?= tr('bud_update_btn') ?></button>
<?php endif; ?>
</div>
<div class="bud-kpi-card">
<div class="bud-kpi-label"><?= tr('bud_theoretical_balance') ?></div>
<div class="bud-kpi-value <?= $solde_theorique < 0 ? 'bud-kpi-value--danger' : '' ?>">
<?= number_format($solde_theorique, 2, ',', ' ') ?> €
</div>
</div>
<div class="bud-kpi-card bud-kpi-card--danger">
<div class="bud-kpi-label bud-kpi-label--danger">
<span><?= tr('bud_upcoming_charges') ?></span>
<button onclick="toggleDiv('pendingDetailsList')" class="bud-pending-toggle">👁️</button>
</div>
<div class="bud-kpi-value bud-kpi-value--danger">
- <?= number_format($reste_a_venir, 2, ',', ' ') ?> €
</div>
<div id="pendingDetailsList" class="bud-pending-list" style="display:none;">
<?php if (empty($pending_charges)): ?>
<div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div>
<?php else: ?>
<?php foreach($pending_charges as $pc): ?>
<div class="bud-pending-item" <?= !empty($pc['tooltip']) ? 'title="'.htmlspecialchars($pc['tooltip']).'" style="cursor:help;"' : '' ?>>
<span><?= htmlspecialchars($pc['name']) ?></span>
<strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<?php
// --- 1. CALCULS DE L'ÉCHELLE DYNAMIQUE ---
@@ -534,92 +555,91 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
}
?>
<div style="margin-top: 65px; margin-bottom: 75px; position: relative; font-family: inherit;">
<div class="bud-scale-wrapper">
<div class="bud-scale-track">
<?php if ($posZero > 0): ?>
<div class="bud-scale-zero" style="width: <?= $posZero ?>%;"></div>
<?php endif; ?>
<div style="position: relative; height: 24px; background: #e2e8f0; border-radius: 12px; width: 100%; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);">
<?php if ($posZero > 0): ?>
<div style="position: absolute; left: 0; width: <?= $posZero ?>%; height: 100%; background: #fef2f2; border-radius: 12px 0 0 12px;"></div>
<?php endif; ?>
<div class="bud-scale-bar" style="left: <?= $leftActuel ?>%; width: <?= $widthActuel ?>%; border-radius: <?= $solde_actuel >= 0 ? '0 12px 12px 0' : '12px' ?>;"></div>
<div style="position: absolute; left: <?= $leftActuel ?>%; width: <?= $widthActuel ?>%; height: 100%; background: repeating-linear-gradient(45deg, #fbbf24, #fbbf24 10px, #f59e0b 10px, #f59e0b 20px); border-radius: <?= $solde_actuel >= 0 ? '0 12px 12px 0' : '12px' ?>; transition: width 0.5s ease-out;"></div>
<?php if ($solde_theorique < 0): ?>
<div class="bud-scale-bar--danger" style="left: <?= $leftDanger ?>%; width: <?= $widthDanger ?>%;"></div>
<?php endif; ?>
</div>
<?php if ($solde_theorique < 0): ?>
<div style="position: absolute; left: <?= $leftDanger ?>%; width: <?= $widthDanger ?>%; height: 100%; background: repeating-linear-gradient(45deg, #ef4444, #ef4444 10px, #dc2626 10px, #dc2626 20px); border-radius: 12px 0 0 12px; opacity: 0.95;"></div>
<?php endif; ?>
</div>
<div class="bud-scale-marker bud-scale-marker--zero" style="left: <?= $posZero ?>%;"></div>
<div class="bud-scale-label bud-scale-label--zero" style="left: <?= $posZero ?>%;">0 €</div>
<div style="position: absolute; left: <?= $posZero ?>%; top: -5px; height: 35px; width: 2px; background: #94a3b8; z-index: 10;"></div>
<div style="position: absolute; left: <?= $posZero ?>%; top: 32px; transform: translateX(-50%); font-size: 0.85rem; font-weight: 800; color: #64748b;">0 €</div>
<div class="bud-scale-marker bud-scale-marker--actual" style="left: <?= $posActuel ?>%;"></div>
<div class="bud-scale-label bud-scale-label--actual" style="left: <?= $posActuel ?>%;"><?= tr('bud_bar_actual') ?? 'Actuel' ?></div>
<div style="position: absolute; left: <?= $posActuel ?>%; top: -15px; height: 40px; width: 3px; background: #1e293b; z-index: 15;"></div>
<div style="position: absolute; left: <?= $posActuel ?>%; top: -35px; transform: translateX(-50%); font-size: 0.8rem; font-weight: bold; color: #1e293b;"><?= tr('bud_bar_actual') ?? 'Actuel' ?></div>
<?php $colorTheo = $solde_theorique >= 0 ? 'var(--primary)' : 'var(--danger)'; ?>
<div class="bud-scale-marker bud-scale-marker--theo" style="left: <?= $posTheorique ?>%; background: <?= $colorTheo ?>;"></div>
<div class="bud-scale-label bud-scale-label--theo" style="left: <?= $posTheorique ?>%; color: <?= $colorTheo ?>;"><?= tr('bud_bar_theoretical') ?? 'Théorique' ?></div>
<div style="position: absolute; left: <?= $posTheorique ?>%; top: 24px; height: 30px; width: 3px; background: <?= $solde_theorique >= 0 ? '#8b5cf6' : '#ef4444' ?>; z-index: 12;"></div>
<div style="position: absolute; left: <?= $posTheorique ?>%; top: 54px; transform: translateX(-50%); font-size: 0.85rem; font-weight: bold; color: <?= $solde_theorique >= 0 ? '#8b5cf6' : '#ef4444' ?>; white-space: nowrap;"><?= tr('bud_bar_theoretical') ?? 'Théorique' ?></div>
<?php
$overlapMax = abs($posMax - $posActuel) < 35;
$topMaxText = $overlapMax ? '-60px' : '-35px';
$heightMaxLine = $overlapMax ? '65px' : '40px';
$topMaxLine = $overlapMax ? '-40px' : '-15px';
?>
<div style="position: absolute; left: <?= $posMax ?>%; top: <?= $topMaxLine ?>; height: <?= $heightMaxLine ?>; width: 2px; background: #cbd5e1; z-index: 10;"></div>
<div style="position: absolute; left: <?= $posMax ?>%; top: <?= $topMaxText ?>; transform: translateX(calc(-100% + 10px)); font-size: 0.8rem; font-weight: bold; color: #64748b; white-space: nowrap; cursor: help;" title="<?= sprintf(tr('bud_capacity_tooltip'), number_format($solde_initial, 0), number_format($salaires_retenus, 0), number_format($rentrees_autres, 0)) ?>">
<?= tr('bud_bar_max_cap') ?? 'Capacité Max' ?> : <?= number_format($capacite_max, 0, ',', ' ') ?> € ℹ️
</div>
<?php
$overlapMax = abs($posMax - $posActuel) < 35;
$topMaxText = $overlapMax ? '-60px' : '-35px';
$heightMaxLine = $overlapMax ? '65px' : '40px';
$topMaxLine = $overlapMax ? '-40px' : '-15px';
?>
<div class="bud-scale-marker bud-scale-marker--max" style="left: <?= $posMax ?>%; top: <?= $topMaxLine ?>; height: <?= $heightMaxLine ?>;"></div>
<div class="bud-scale-label bud-scale-label--max" style="left: <?= $posMax ?>%; top: <?= $topMaxText ?>; transform: translateX(calc(-100% + 10px));" title="<?= sprintf(tr('bud_capacity_tooltip'), number_format($solde_initial, 0), number_format($salaires_retenus, 0), number_format($rentrees_autres, 0)) ?>">
<?= tr('bud_bar_max_cap') ?? 'Capacité Max' ?> : <?= number_format($capacite_max, 0, ',', ' ') ?> € ℹ️
</div>
</div>
</div>
<div class="categories-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:24px;">
<?php foreach ($categoriesConfig as $key => $conf): ?>
<div class="cat-card" <?= $isClosed ? 'style="opacity:0.8;"' : '' ?>>
<div class="cat-card-header" style="background:<?= $conf['color'] ?>15; border-bottom:1px solid <?= $conf['color'] ?>30;">
<div class="cat-card-title-group">
<h3 style="margin:0; font-size:1rem; color:<?= $conf['color'] ?>;"><?= $conf['label'] ?></h3>
<div class="cat-card-subtitle">
<?php $logic = getDisplayLogic($totals[$key], $conf['budget'], $conf['type']); echo $logic['text']; ?>
</div>
<div class="bud-categories-grid">
<?php foreach ($categoriesConfig as $key => $conf): ?>
<div class="cat-card" <?= $isClosed ? 'style="opacity:0.8;"' : '' ?>>
<div class="cat-card-header" style="background:<?= $conf['color'] ?>15; border-bottom:1px solid <?= $conf['color'] ?>30;">
<div class="cat-card-title-group">
<h3 style="margin:0; font-size:1rem; color:<?= $conf['color'] ?>;"><?= $conf['label'] ?></h3>
<div class="cat-card-subtitle">
<?php $logic = getDisplayLogic($totals[$key], $conf['budget'], $conf['type']); echo $logic['text']; ?>
</div>
<?php if (!$isClosed): ?>
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes(strip_tags($conf['label'])) ?>')">+</button>
<?php endif; ?>
</div>
<?php $barCol = ($conf['db_type'] === 'Income') ? '#10b981' : ($logic['isOver'] ? '#ef4444' : $conf['color']); ?>
<div style="background:#f1f5f9; height:4px; width:100%;">
<div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div>
</div>
<div style="flex:1; max-height:300px; overflow-y:auto; padding:0;">
<?php if (empty($expensesByCategory[$key])): ?>
<div style="padding:20px; text-align:center; color:#cbd5e1; font-style:italic; font-size:0.85rem;"><?= tr('bud_no_lines') ?></div>
<?php else: ?>
<table style="width:100%; border-collapse:collapse; font-size:0.85rem;">
<?php foreach ($expensesByCategory[$key] as $exp): ?>
<tr style="border-bottom:1px solid #f8fafc;">
<td style="padding:10px 15px; color:#94a3b8;"><?= date('d/m', strtotime($exp['date_exp'])) ?></td>
<td style="padding:10px 5px; font-weight:500;">
<?= htmlspecialchars($exp['label']) ?>
</td>
<td style="padding:10px 15px; text-align:right; font-weight:600; color:<?= $exp['amount'] > 0 ? '#10b981' : '#1e293b' ?>;">
<?= $exp['amount'] > 0 ? '+' : '-' ?><?= number_format(abs($exp['amount']), 2) ?>
</td>
<?php if (!$isClosed): ?>
<td style="width:70px; padding-right:10px; text-align:right; white-space:nowrap;">
<button class="btn-icon-action edit" onclick='openEditModal(<?= json_encode($exp, JSON_HEX_APOS | JSON_HEX_QUOT) ?>)' title="<?= tr('edit') ?>">✏️</button>
<button class="btn-icon-action delete btn-safe-click" onclick="deleteExpense(<?= $exp['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div>
<?php if (!$isClosed): ?>
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes(strip_tags($conf['label'])) ?>')">+</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php $barCol = ($conf['db_type'] === 'Income') ? 'var(--success)' : ($logic['isOver'] ? 'var(--danger)' : $conf['color']); ?>
<div style="background:var(--bg-page); height:4px; width:100%;">
<div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div>
</div>
<div style="flex:1; max-height:300px; overflow-y:auto; padding:0;">
<?php if (empty($expensesByCategory[$key])): ?>
<div style="padding:20px; text-align:center; color:var(--text-muted); font-style:italic; font-size:0.85rem;"><?= tr('bud_no_lines') ?></div>
<?php else: ?>
<table class="bud-expense-table">
<?php foreach ($expensesByCategory[$key] as $exp): ?>
<tr class="bud-expense-row">
<td class="bud-expense-date"><?= date('d/m', strtotime($exp['date_exp'])) ?></td>
<td class="bud-expense-label">
<?= htmlspecialchars($exp['label']) ?>
</td>
<td class="bud-expense-amount" style="color:<?= $exp['amount'] > 0 ? 'var(--success)' : 'var(--text-main)' ?>;">
<?= $exp['amount'] > 0 ? '+' : '-' ?><?= number_format(abs($exp['amount']), 2) ?>
</td>
<?php if (!$isClosed): ?>
<td class="bud-expense-actions">
<button class="btn-icon-action edit" onclick='openEditModal(<?= json_encode($exp, JSON_HEX_APOS | JSON_HEX_QUOT) ?>)' title="<?= tr('edit') ?>">✏️</button>
<button class="btn-icon-action delete btn-safe-click" onclick="deleteExpense(<?= $exp['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
@@ -1021,11 +1041,11 @@ document.addEventListener('DOMContentLoaded', () => {
formExpense.reset();
window.location.reload();
} else {
alert((window.I18N['error_occured'] || 'Erreur') + ' : ' + (result.error || 'Inconnue'));
showToast((window.I18N['error_occured'] || 'Erreur') + ' : ' + (result.error || 'Inconnue'), 'error');
}
} catch (error) {
console.error("Erreur réseau :", error);
alert(window.I18N['bud_err_tech'] || "Erreur critique réseau.");
showToast(window.I18N['bud_err_tech'] || "Erreur critique réseau.", 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerText = originalBtnText;
+197 -69
View File
@@ -10,15 +10,26 @@
#f1f5f9 8px,
#f1f5f9 16px
);
--c-off-carole: #fef3c7;
--c-extra-off: #fee2e2;
--c-selected: #dbeafe;
/* Thèmes Parents (Sync Budget) */
--bg-alex: #ecfeff;
--text-alex: #0891b2;
--bg-laia: #fffbeb;
--text-laia: #d97706;
/* Palette dynamique pour les congés intervenants */
--fc-palette-0: #fef3c7;
--fc-palette-text-0: #b45309;
--fc-palette-1: #fee2e2;
--fc-palette-text-1: #b91c1c;
--fc-palette-2: #dbeafe;
--fc-palette-text-2: #1d4ed8;
--fc-palette-3: #dcfce7;
--fc-palette-text-3: #15803d;
--fc-palette-4: #f3e8ff;
--fc-palette-text-4: #7e22ce;
--fc-palette-5: #ffedd5;
--fc-palette-text-5: #c2410c;
--fc-palette-6: #ccfbf1;
--fc-palette-text-6: #0f766e;
--fc-palette-7: #fce7f3;
--fc-palette-text-7: #be185d;
--c-selected: #dbeafe;
/* Rappel des variables Global Design System pour cohérence */
--pf-primary: #3b82f6;
@@ -301,11 +312,30 @@
background: var(--c-public-holiday) !important;
font-weight: 700;
}
.fc-day--off-carole {
background: var(--c-off-carole) !important;
.fc-bg-palette-0 {
background: var(--fc-palette-0) !important;
}
.fc-day--extra-off-carole {
background: var(--c-extra-off) !important;
.fc-bg-palette-1 {
background: var(--fc-palette-1) !important;
}
.fc-bg-palette-2 {
background: var(--fc-palette-2) !important;
}
.fc-bg-palette-3 {
background: var(--fc-palette-3) !important;
}
.fc-bg-palette-4 {
background: var(--fc-palette-4) !important;
}
.fc-bg-palette-5 {
background: var(--fc-palette-5) !important;
}
.fc-bg-palette-6 {
background: var(--fc-palette-6) !important;
}
.fc-bg-palette-7 {
background: var(--fc-palette-7) !important;
}
.fc-day--selected {
@@ -314,8 +344,14 @@
z-index: 5;
}
.fc-day--off-carole.fc-day--school-holiday,
.fc-day--extra-off-carole.fc-day--school-holiday,
.fc-bg-palette-0.fc-day--school-holiday,
.fc-bg-palette-1.fc-day--school-holiday,
.fc-bg-palette-2.fc-day--school-holiday,
.fc-bg-palette-3.fc-day--school-holiday,
.fc-bg-palette-4.fc-day--school-holiday,
.fc-bg-palette-5.fc-day--school-holiday,
.fc-bg-palette-6.fc-day--school-holiday,
.fc-bg-palette-7.fc-day--school-holiday,
.fc-day--centre.fc-day--school-holiday,
.fc-day--avis.fc-day--school-holiday {
box-shadow: inset 0 -8px 0 0 var(--c-school-holiday) !important;
@@ -409,15 +445,23 @@
z-index: 20;
}
.col-alex.header-group,
.col-alex-sub {
background: var(--bg-alex) !important;
color: var(--text-alex) !important;
.col-p1.header-group,
.col-p1-sub {
background: color-mix(
in srgb,
var(--p1-main, #0891b2) 8%,
#ffffff
) !important;
color: color-mix(in srgb, var(--p1-main, #0891b2) 85%, #000000) !important;
}
.col-laia.header-group,
.col-laia-sub {
background: var(--bg-laia) !important;
color: var(--text-laia) !important;
.col-p2.header-group,
.col-p2-sub {
background: color-mix(
in srgb,
var(--p2-main, #f59e0b) 8%,
#ffffff
) !important;
color: color-mix(in srgb, var(--p2-main, #f59e0b) 85%, #000000) !important;
}
#planningTable tbody td {
@@ -448,10 +492,10 @@ td.col-day {
}
th.col-total,
td.col-total,
th.col-alex-sub,
td.col-alex-sub,
th.col-laia-sub,
td.col-laia-sub {
th.col-p1-sub,
td.col-p1-sub,
th.col-p2-sub,
td.col-p2-sub {
width: 32px !important;
min-width: 32px !important;
max-width: 32px !important;
@@ -481,7 +525,7 @@ td.col-laia-sub {
width: 40px;
min-width: 40px;
text-align: center;
padding: 4px; /* Réduit les marges internes */
padding-bottom: 10px; /* Réduit les marges internes */
}
/* 3. On s'assure que le tableau utilise tout l'espace disponible intelligemment */
@@ -536,7 +580,7 @@ td.col-laia-sub {
#planningTable tbody td.col-sticky-sem {
writing-mode: horizontal-tb;
transform: none;
padding: 6px !important;
padding-bottom: 10px !important;
text-align: center;
}
@@ -594,12 +638,7 @@ td.col-laia-sub {
.fc-legend-public-holiday {
background: var(--c-public-holiday);
}
.fc-legend-off-carole {
background: var(--c-off-carole);
}
.fc-legend-extra-off-carole {
background: var(--c-extra-off);
}
.fc-legend-centre::after {
content: "🏫";
}
@@ -660,27 +699,32 @@ td.col-laia-sub {
#selectionMenu,
#fc-month-selectionMenu,
.fc-selection-menu {
position: absolute;
z-index: 9000;
position: fixed !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
z-index: 9999;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border: 1px solid rgba(226, 232, 240, 0.8);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2) !important;
border-radius: 12px;
padding: 8px 12px;
min-width: 210px;
padding: 16px;
width: 90%;
max-width: 320px;
max-height: 85vh;
overflow-y: auto;
display: none;
animation: menuPopIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform-origin: top center;
animation: menuPopCenter 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards !important;
}
@keyframes menuPopIn {
@keyframes menuPopCenter {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
transform: translate(-50%, -45%) scale(0.95);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
transform: translate(-50%, -50%) scale(1);
}
}
@@ -917,30 +961,6 @@ td.col-laia-sub {
padding: 10px;
}
#selectionMenu,
#fc-month-selectionMenu,
.fc-selection-menu {
position: fixed !important;
top: auto !important;
left: 0 !important;
bottom: 0 !important;
width: 100% !important;
border-radius: 24px 24px 0 0;
padding: 24px 20px;
transform: translateY(100%);
animation: slideUpSheet 0.3s forwards;
z-index: 10000 !important;
}
#selectionMenu::before,
#fc-month-selectionMenu::before {
content: "";
display: block;
width: 40px;
height: 5px;
background: #cbd5e1;
border-radius: 10px;
margin: 0 auto 15px auto;
}
.fc-two-months-container,
.fc-three-months-container {
display: flex !important;
@@ -977,7 +997,6 @@ td.col-laia-sub {
font-size: 0.85rem !important;
}
/* 3. CORRECTION CRITIQUE : Le décalage "Sticky" de la colonne Semaine */
/* Puisque la colonne Mois fait 35px, la colonne Semaine doit démarrer à 35px (et non plus 54px) */
#planningTable tbody td.col-sticky-sem,
#planningTable thead tr th.col-sticky-sem {
@@ -1037,3 +1056,112 @@ td.col-laia-sub {
#planningTable thead tr:nth-child(3) th {
top: 64px !important;
}
#planningTable th.rotated-text {
height: 120px; /* Augmente la hauteur pour laisser respirer le texte */
vertical-align: bottom;
padding-bottom: 10px !important;
}
.rotated-text span {
white-space: nowrap; /* Force le texte sur une seule ligne */
display: inline-block;
}
/* --- RÉCAPITULATIF INTERVENANT (DASHBOARD) --- */
.fc-recap-controls {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.fc-recap-custom-dates {
display: flex;
gap: 8px;
align-items: center;
background: var(--pf-bg-lighter);
padding: 4px 8px;
border-radius: 8px;
border: 1px solid var(--pf-border);
}
.fc-recap-kpi-container {
background: var(--pf-bg-lighter);
border-radius: 12px;
border: 1px solid var(--pf-border);
padding: 20px;
margin-top: 16px;
}
.fc-recap-kpi-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--pf-border);
}
.fc-recap-kpi-main-label {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.fc-recap-kpi-main-val {
font-size: 1.8rem;
font-weight: 800;
color: var(--pf-primary);
line-height: 1;
}
.fc-recap-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
}
.fc-recap-kpi-card {
background: var(--bg-panel);
border: 1px solid var(--pf-border);
border-radius: 8px;
padding: 16px;
text-align: center;
box-shadow: var(--pf-shadow-sm);
transition: border-color 0.2s;
}
.fc-recap-kpi-card:hover {
border-color: var(--warning);
}
.fc-recap-kpi-label {
font-size: 0.8rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 8px;
}
.fc-recap-kpi-val {
font-size: 1.5rem;
font-weight: 800;
color: var(--warning);
line-height: 1;
}
.fc-recap-empty {
text-align: center;
color: var(--text-muted);
font-style: italic;
padding: 20px;
grid-column: 1 / -1;
}
.fc-recap-error {
text-align: center;
color: var(--danger);
font-weight: bold;
padding: 20px;
}
[data-theme="dark"] .fc-recap-custom-dates,
[data-theme="dark"] .fc-recap-kpi-container {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .fc-recap-kpi-card {
background: var(--bg-page);
}
File diff suppressed because it is too large. Load diff
@@ -15,7 +15,7 @@ try {
$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);
$stmtLeaves = $pdo->query("SELECT person_id, leave_type, anniversary_date, method, allowance FROM pf_person_leave_meta");
$stmtLeaves = $pdo->query("SELECT person_id, leave_type, anniversary_date, method, allowance, carry_over_max_days, carry_over_deadline_month FROM pf_person_leave_meta");
$leavesRaw = $stmtLeaves->fetchAll(PDO::FETCH_ASSOC);
$leavesMap = [];
@@ -24,7 +24,9 @@ try {
'type' => $leave['leave_type'],
'method' => $leave['method'] ?? 'FIXED',
'allowance' => $leave['allowance'] ?? 0,
'date' => $leave['anniversary_date']
'date' => $leave['anniversary_date'],
'carry_over_max_days' => $leave['carry_over_max_days'] ?? 0,
'carry_over_deadline_month' => $leave['carry_over_deadline_month'] ?? 12
];
}
@@ -37,8 +39,8 @@ try {
'calendar_working_hours' => $calendarSettingsRaw['calendar_working_hours'] ?? '08:00-19:00'
];
// 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");
// Catalogue des congés (Nettoyé des règles de report qui sont maintenant sur la personne)
$stmtLeaveTypes = $pdo->query("SELECT code, label, default_allowance, reset_month FROM pf_leave_types ORDER BY label ASC");
$leaveTypes = $stmtLeaveTypes->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
@@ -97,20 +99,22 @@ try {
}
if ($action === 'save_leave_type') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception(tr('error_occured'));
$mode = $_POST['mode'] ?? 'add';
$code = strtoupper(trim($_POST['code'] ?? ''));
$label = trim($_POST['label'] ?? '');
$carryMax = (float)($_POST['carry_over_max_days'] ?? 0);
$carryDeadline = (int)($_POST['carry_over_deadline_month'] ?? 12);
if (empty($code) || empty($label)) throw new Exception("Le code et le label sont obligatoires.");
if (empty($code) || empty($label)) throw new Exception(tr('fc_err_code_label'));
if ($mode === 'add') {
$stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label) VALUES (?, ?)");
$stmt->execute([$code, $label]);
$stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label, carry_over_max_days, carry_over_deadline_month) VALUES (?, ?, ?, ?)");
$stmt->execute([$code, $label, $carryMax, $carryDeadline]);
} else {
$stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ? WHERE code = ?");
$stmt->execute([$label, $code]);
$stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ?, carry_over_max_days = ?, carry_over_deadline_month = ? WHERE code = ?");
$stmt->execute([$label, $carryMax, $carryDeadline, $code]);
}
echo json_encode(['success' => true]);
exit;
@@ -141,7 +145,7 @@ try {
// ─── 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, method, anniversary_date FROM pf_person_leave_meta WHERE person_id = ? ORDER BY leave_type ASC");
$stmt = $pdo->prepare("SELECT id, leave_type, allowance, method, anniversary_date, carry_over_max_days, carry_over_deadline_month 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;
@@ -150,25 +154,35 @@ try {
if ($action === 'add_person_leave') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$id = (int)($_POST['meta_id'] ?? 0); // Nouvel ID pour différencier l'Update de l'Insert
$personId = (int)($_POST['person_id'] ?? 0);
$type = strtoupper(trim($_POST['leave_type'] ?? ''));
$allowance = (float)($_POST['allowance'] ?? 0);
$resetMonth = (int)($_POST['reset_month'] ?? 1);
$carryMax = (float)($_POST['carry_over_max_days'] ?? 0);
$carryDeadline = (int)($_POST['carry_over_deadline_month'] ?? 12);
$method = in_array($_POST['method'] ?? '', ['FIXED', 'ACCUMULATED']) ? $_POST['method'] : 'FIXED';
if ($personId <= 0 || empty($type)) throw new Exception("Données invalides.");
// Vérification des doublons
$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 (?, ?, ?, ?, ?)");
$stmt->execute([$personId, $type, $allowance, $method, $anniversaryDate]);
if ($id > 0) {
// ✏️ UPDATE (Modification)
$stmt = $pdo->prepare("UPDATE pf_person_leave_meta SET allowance = ?, method = ?, anniversary_date = ?, carry_over_max_days = ?, carry_over_deadline_month = ? WHERE id = ? AND person_id = ?");
$stmt->execute([$allowance, $method, $anniversaryDate, $carryMax, $carryDeadline, $id, $personId]);
} else {
// ➕ INSERT (Ajout)
// Vérification des doublons
$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.");
$stmt = $pdo->prepare("INSERT INTO pf_person_leave_meta (person_id, leave_type, allowance, method, anniversary_date, carry_over_max_days, carry_over_deadline_month) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$personId, $type, $allowance, $method, $anniversaryDate, $carryMax, $carryDeadline]);
}
echo json_encode(['success' => true]);
exit;
@@ -4,7 +4,7 @@ require __DIR__ . '/../../../../includes/db.php';
try {
$stmt = $pdo->query("
SELECT person_id, leave_type, snapshot_date, remaining_balance
SELECT person_id, leave_type, snapshot_date, remaining_balance, carry_over_balance
FROM pf_leave_snapshots
ORDER BY snapshot_date ASC
");
@@ -15,13 +15,16 @@ try {
$stmtDel = $pdo->prepare("DELETE FROM pf_leave_snapshots WHERE person_id = ? AND leave_type = ? AND snapshot_date = ?");
$stmtDel->execute([$input['person_id'], $input['leave_type'], $input['snapshot_date']]);
$carry_over_balance = isset($input['carry_over_balance']) ? (float)$input['carry_over_balance'] : 0.00;
// Étape 2 : On insère le nouveau solde
$stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance) VALUES (?, ?, ?, ?)");
$stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance, carry_over_balance) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([
$input['person_id'],
$input['leave_type'],
$input['snapshot_date'],
$input['remaining_balance']
$input['remaining_balance'],
$carry_over_balance
]);
echo json_encode(['status' => 'success']);
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,7 @@
<?php
// modules/food/includes/api/api-liste.php
ob_start();
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_once dirname(__DIR__, 4) . '/includes/auth.php';
require_login();
header('Content-Type: application/json');
@@ -10,7 +11,7 @@ set_exception_handler(function (\Throwable $e) {
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
});
require_once dirname(__DIR__, 2) . '/includes/db.php';
require_once dirname(__DIR__, 4) . '/includes/db.php';
// ── Auto-create tables ────────────────────────────────────────────────────────
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
@@ -96,11 +97,9 @@ function liste_ensure_default(PDO $pdo): int {
return (int)$pdo->query("SELECT id FROM pf_lists ORDER BY position, id LIMIT 1")->fetchColumn();
}
// Built-in keyword → category position mapping (position maps to seeded order above)
function liste_builtin_detect(string $normalized): ?int {
static $map = null;
if ($map === null) $map = [
// 1 = Fruits & Légumes
'pomme'=>1,'poire'=>1,'banane'=>1,'orange'=>1,'citron'=>1,'raisin'=>1,
'fraise'=>1,'framboise'=>1,'myrtille'=>1,'cerise'=>1,'peche'=>1,'abricot'=>1,
'mangue'=>1,'kiwi'=>1,'ananas'=>1,'melon'=>1,'pasteque'=>1,'prune'=>1,
@@ -113,120 +112,96 @@ function liste_builtin_detect(string $normalized): ?int {
'gingembre'=>1,'avocat'=>1,'endive'=>1,'ciboulette'=>1,'persil'=>1,
'basilic'=>1,'menthe'=>1,'thym'=>1,'romarin'=>1,'coriandre'=>1,
'girofle'=>1,'citron vert'=>1,'lime'=>1,'pamplemousse'=>1,'litchi'=>1,
// 2 = Viande
'boeuf'=>2,'veau'=>2,'poulet'=>2,'porc'=>2,'agneau'=>2,'dinde'=>2,
'lapin'=>2,'jambon'=>2,'lardon'=>2,'saucisse'=>2,'saucisson'=>2,
'merguez'=>2,'chipolata'=>2,'steak'=>2,'escalope'=>2,'roti'=>2,
'gigot'=>2,'filet'=>2,'cuisses'=>2,'viande hachee'=>2,'andouille'=>2,
'magret'=>2,'canard'=>2,'foie'=>2,'boudin'=>2,'paupiette'=>2,
'entrecote'=>2,'cote de boeuf'=>2,'onglet'=>2,'bavette'=>2,
// 3 = Poissonnerie
'saumon'=>3,'thon'=>3,'dorade'=>3,'bar'=>3,'cabillaud'=>3,'sole'=>3,
'truite'=>3,'lieu'=>3,'maquereau'=>3,'sardine'=>3,'anchois'=>3,
'crevette'=>3,'moule'=>3,'huitre'=>3,'coquille'=>3,'poulpe'=>3,
'seiche'=>3,'langoustine'=>3,'lotte'=>3,'merlu'=>3,'colin'=>3,
'tilapia'=>3,'daurade'=>3,'aiglefin'=>3,'homard'=>3,'crabe'=>3,
// 4 = Boulangerie
'pain'=>4,'baguette'=>4,'brioche'=>4,'croissant'=>4,'pain de mie'=>4,
'ficelle'=>4,'fougasse'=>4,'ciabatta'=>4,'biscotte'=>4,'naan'=>4,
'pita'=>4,'toast'=>4,'bagel'=>4,'pain burger'=>4,'wrap'=>4,
// 5 = Frais
'lait'=>5,'creme'=>5,'yaourt'=>5,'yourt'=>5,'beurre'=>5,
'fromage'=>5,'camembert'=>5,'brie'=>5,'comte'=>5,'gruyere'=>5,
'emmental'=>5,'mozzarella'=>5,'ricotta'=>5,'mascarpone'=>5,
'feta'=>5,'chevre'=>5,'oeuf'=>5,'oeufs'=>5,'creme fraiche'=>5,
'fromage blanc'=>5,'petit suisse'=>5,'kefir'=>5,
// 6 = Surgelés
'surgele'=>6,'pizza surgelee'=>6,'frite surgelee'=>6,'poisson pane'=>6,
'legume surgele'=>6,'glace'=>6,'sorbet'=>6,'plat surgele'=>6,
'nugget'=>6,'edamame'=>6,'petits pois'=>6,'epinards'=>6,
// 7 = Boissons
'eau'=>7,'coca'=>7,'cola'=>7,'limonade'=>7,'sirop'=>7,
'biere'=>7,'vin'=>7,'champagne'=>7,'cafe'=>7,'the'=>7,
'tisane'=>7,'soda'=>7,'jus'=>7,'kombucha'=>7,'cidre'=>7,
'whisky'=>7,'rhum'=>7,'vodka'=>7,'prosecco'=>7,'rose'=>7,
// 8 = Pâtes, Riz, Féculents
'pate'=>8,'spaghetti'=>8,'tagliatelle'=>8,'penne'=>8,'fusilli'=>8,
'macaroni'=>8,'riz'=>8,'couscous'=>8,'quinoa'=>8,'lentille'=>8,
'pois chiche'=>8,'farine'=>8,'semoule'=>8,'polenta'=>8,'boulgour'=>8,
'orge'=>8,'feculent'=>8,'vermicelle'=>8,'nouille'=>8,'gnocchi'=>8,
// 9 = Épicerie salée
'chips'=>9,'crackers'=>9,'olives'=>9,'cornichon'=>9,'capres'=>9,
'tapenade'=>9,'houmous'=>9,'tzatziki'=>9,'sel'=>9,'poivre'=>9,
'epice'=>9,'cube bouillon'=>9,'bouillon'=>9,'noix de cajou'=>9,
'amande'=>9,'pistache'=>9,'noix'=>9,'cacahuete'=>9,
// 10 = Conserves
'conserve'=>10,'boite de tomate'=>10,'concentre de tomate'=>10,
'mais'=>10,'ratatouille'=>10,'cassoulet'=>10,'pate de foie'=>10,
'sardine boite'=>10,'thon boite'=>10,'maquereau boite'=>10,
'soupe boite'=>10,'haricot boite'=>10,
// 11 = Plats cuisinés
'quiche'=>11,'lasagne'=>11,'gratin'=>11,'pizza'=>11,'tartiflette'=>11,
'croque'=>11,'hachis parmentier'=>11,'paella'=>11,'moussaka'=>11,
'tarte'=>11,'flamiche'=>11,'pissaladiere'=>11,
// 12 = Sauces & Condiments
'ketchup'=>12,'mayonnaise'=>12,'moutarde'=>12,'vinaigrette'=>12,
'sauce soja'=>12,'tabasco'=>12,'huile'=>12,'vinaigre'=>12,
'sauce tomate'=>12,'pesto'=>12,'sriracha'=>12,'worcester'=>12,
'nuoc mam'=>12,'sauce'=>12,'condiment'=>12,'worcestershire'=>12,
// 13 = Petit déjeuner
'cereale'=>13,'muesli'=>13,'granola'=>13,'corn flakes'=>13,
'miel'=>13,'confiture'=>13,'nutella'=>13,'beurre de cacahuete'=>13,
'sirop d erable'=>13,'sirop erable'=>13,'chocolat en poudre'=>13,
// 14 = Biscuits & Gâteaux
'biscuit'=>14,'gateau'=>14,'cookie'=>14,'madeleine'=>14,
'financier'=>14,'quatre quarts'=>14,'brownie'=>14,'sable'=>14,
'speculoos'=>14,'oreo'=>14,'lu'=>14,'petit beurre'=>14,'galette'=>14,
'palmier'=>14,'macaron'=>14,'eclair'=>14,
// 15 = Confiserie
'chocolat'=>15,'bonbon'=>15,'reglisse'=>15,'caramel'=>15,
'nougat'=>15,'marshmallow'=>15,'guimauve'=>15,'sucette'=>15,
'chewing gum'=>15,'pastille'=>15,'calisson'=>15,
// 16 = Dessert
'yaourt dessert'=>16,'creme dessert'=>16,'mousse au chocolat'=>16,
'tiramisu'=>16,'creme brulee'=>16,'flan'=>16,'crepe'=>16,
'madeleine'=>16,'profiterole'=>16,'eclair'=>16,
// 17 = Beauté & Hygiène
'profiterole'=>16,
'shampoing'=>17,'gel douche'=>17,'savon'=>17,'dentifrice'=>17,
'deodorant'=>17,'crème visage'=>17,'crème corps'=>17,'rasoir'=>17,
'mousse a raser'=>17,'coton'=>17,'lingette'=>17,'maquillage'=>17,
'parfum'=>17,'brosse a dents'=>17,'fil dentaire'=>17,'serum'=>17,
'hydratant'=>17,'demaquillant'=>17,
// 18 = Bébé
'couche'=>18,'biberon'=>18,'lait infantile'=>18,'compote bebe'=>18,
'pot bebe'=>18,'puree bebe'=>18,'lingette bebe'=>18,'savon bebe'=>18,
'creme bebe'=>18,'sucette bebe'=>18,
// 19 = Entretien
'lessive'=>19,'liquide vaisselle'=>19,'nettoyant'=>19,'degraissant'=>19,
'deboucheur'=>19,'anticalcaire'=>19,'eponge'=>19,'serpillere'=>19,
'sac poubelle'=>19,'papier toilette'=>19,'sopalin'=>19,'essuie tout'=>19,
'aluminium'=>19,'film plastique'=>19,'sac congelation'=>19,
'nettoyant wc'=>19,'desinfectant'=>19,'vitre'=>19,'javel'=>19,
// 20 = Animaux
'croquette'=>20,'patee'=>20,'litiere'=>20,'os'=>20,
'friandise animale'=>20,'nourriture chat'=>20,'nourriture chien'=>20,
// 21 = Maison & Jardin
'ampoule'=>21,'pile'=>21,'bougie'=>21,'allumette'=>21,
'ruban adhesif'=>21,'colle'=>21,'vis'=>21,'terreau'=>21,
'engrais'=>21,'pot de fleur'=>21,'arrosoir'=>21,'graine'=>21,
'tournevis'=>21,'marteau'=>21,'clou'=>21,'cle'=>21,
// 22 = Pharmacie
'doliprane'=>22,'ibuprofene'=>22,'paracetamol'=>22,'aspirine'=>22,
'bandage'=>22,'pansement'=>22,'thermometre'=>22,'serum physiologique'=>22,
'vitamine'=>22,'complement'=>22,'masque'=>22,'gant medical'=>22,
'sirop'=>22,'antihistaminique'=>22,'antidouleur'=>22,
'antihistaminique'=>22,'antidouleur'=>22,
];
// Check full phrase
if (isset($map[$normalized])) return $map[$normalized];
// Check individual words
$words = explode(' ', $normalized);
foreach ($words as $w) {
if (strlen($w) >= 3 && isset($map[$w])) return $map[$w];
}
// Partial match (contains)
foreach ($map as $kw => $catPos) {
if (str_contains($normalized, $kw)) return $catPos;
}
@@ -237,12 +212,10 @@ function liste_builtin_detect(string $normalized): ?int {
function liste_detect_category(PDO $pdo, string $label): ?int {
$norm = liste_normalize_detect($label);
// 1. Learned rules (exact normalized label)
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
$r->execute([$norm]);
if ($row = $r->fetch()) return (int)$row['category_id'];
// 2. Learned rules (individual words)
foreach (explode(' ', $norm) as $w) {
if (strlen($w) < 3) continue;
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
@@ -250,11 +223,9 @@ function liste_detect_category(PDO $pdo, string $label): ?int {
if ($row = $r->fetch()) return (int)$row['category_id'];
}
// 3. Built-in dictionary → map position to real category id
$pos = liste_builtin_detect($norm);
if ($pos === null) return null;
// Get category id by position order
$r = $pdo->query("SELECT id FROM pf_list_categories ORDER BY position, id");
$ids = $r->fetchAll(PDO::FETCH_COLUMN);
return $ids[$pos - 1] ?? null;
@@ -266,7 +237,6 @@ function liste_learn(PDO $pdo, string $label, int $category_id): void {
$pdo->prepare("INSERT INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)
ON DUPLICATE KEY UPDATE category_id=VALUES(category_id)")
->execute([$norm, $category_id]);
// Also learn individual meaningful words
foreach (explode(' ', $norm) as $w) {
if (strlen($w) >= 4) {
$pdo->prepare("INSERT IGNORE INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)")
@@ -301,7 +271,6 @@ if ($action === 'set_category' && $method === 'POST') {
$pdo->prepare("UPDATE pf_grocery_items SET category_id=? WHERE id=?")->execute([$category_id, $item_id]);
// Learn from manual assignment
if ($category_id !== null) {
$row = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?");
$row->execute([$item_id]);
@@ -431,7 +400,7 @@ if ($action === 'clear_all' && $method === 'POST') {
// ── HISTORY ───────────────────────────────────────────────────────────────────
if ($action === 'history' && $method === 'GET') {
$max = 20;
$n = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='setting' AND reference_id='liste_history_max'"); $n->execute();
$n = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'"); $n->execute();
if ($r = $n->fetch()) $max = max(1, min(50, (int)$r['content']));
$s = $pdo->query("SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT $max");
echo json_encode(['history' => $s->fetchAll(PDO::FETCH_COLUMN)]); exit;
+73
View File
@@ -0,0 +1,73 @@
<?php
// Désactive l'affichage des erreurs HTML pour garantir un retour JSON strict
ini_set('display_errors', 0);
error_reporting(0);
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require dirname(__DIR__, 4) . '/includes/i18n.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
try {
$dateParam = $_GET['date'] ?? date('Y-m-d');
$baseDate = new DateTime($dateParam);
// Aligner sur le lundi de la semaine
$monday = clone $baseDate;
if ($monday->format('N') != 1) {
$monday->modify('last monday');
}
$sunday = clone $monday;
$sunday->modify('+6 days');
$weekData = [
'week_label' => tr('meal_week') . ' ' . $monday->format('W'),
'days' => []
];
// Extraction des repas de la base (person_id = 0 pour le foyer global)
$stmt = $pdo->prepare("SELECT plan_date, service, meal_name FROM pf_meals_plan WHERE plan_date BETWEEN ? AND ? AND person_id = 0");
$stmt->execute([$monday->format('Y-m-d'), $sunday->format('Y-m-d')]);
$meals = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$meals[$row['plan_date']][$row['service']] = $row['meal_name'];
}
$current = clone $monday;
// Mappage vers les clés i18n existantes de HouseHub
$daysMap = [
1 => 'day_mon', 2 => 'day_tue', 3 => 'day_wed',
4 => 'day_thu', 5 => 'day_fri', 6 => 'day_sat', 7 => 'day_sun'
];
for ($i = 0; $i < 7; $i++) {
$dStr = $current->format('Y-m-d');
$dayNum = $current->format('N'); // 1 à 7
$monthNum = $current->format('m'); // 01 à 12
$dayDate = $current->format('d'); // 01 à 31
$dayName = tr($daysMap[$dayNum]);
$monthName = tr('month_' . $monthNum);
$weekData['days'][] = [
'date' => $dStr,
'day_name' => $dayName,
'date_formatted' => $dayDate . ' ' . $monthName,
'lunch' => $meals[$dStr]['lunch'] ?? '',
'dinner' => $meals[$dStr]['dinner'] ?? ''
];
$current->modify('+1 day');
}
echo json_encode(['success' => true, 'data' => $weekData], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+277
View File
@@ -0,0 +1,277 @@
<?php
// modules/meals/includes/api/import-pdf.php
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_FILES['pdf_file'])) {
echo json_encode(['success' => false, 'error' => 'Aucun fichier reçu.']);
exit;
}
$personId = (int)$_POST['person_id'];
if ($personId <= 0) {
echo json_encode(['success' => false, 'error' => 'Enfant non spécifié.']);
exit;
}
// ---------------------------------------------------------
// PARSEUR PDF SPATIAL NATIF (100% PHP SANS LIBRAIRIE)
// ---------------------------------------------------------
class NativePdfParser {
public static function getMealData($filename) {
$content = file_get_contents($filename);
$texts = [];
// 1. Décompression et Tokenization Mathématique
if (preg_match_all('/stream(.*?)endstream/is', $content, $matches)) {
foreach ($matches[1] as $stream) {
$stream = ltrim($stream, "\r\n");
$decoded = @gzuncompress($stream);
if (!$decoded) $decoded = $stream;
// On éclate le flux par opérateurs PDF clés (Position et Texte)
$parts = preg_split('/(Tm|Td|Tj|TJ)/', $decoded, -1, PREG_SPLIT_DELIM_CAPTURE);
$x = 0; $y = 0;
for ($i = 0; $i < count($parts); $i++) {
$token = $parts[$i];
if ($token === 'Tm' || $token === 'Td') {
$prev = $parts[$i-1];
preg_match_all('/[0-9.-]+/', $prev, $nums);
if (count($nums[0]) >= 2) {
$yRaw = array_pop($nums[0]);
$xRaw = array_pop($nums[0]);
if ($token === 'Tm') {
$x = (float)$xRaw; $y = (float)$yRaw;
} else {
$x += (float)$xRaw; $y += (float)$yRaw;
}
}
} elseif ($token === 'Tj') {
$prev = $parts[$i-1];
if (preg_match('/\((.*?)\)$/', trim($prev), $m)) {
self::addText($texts, $m[1], $x, $y);
}
} elseif ($token === 'TJ') {
$prev = $parts[$i-1];
if (preg_match('/\[(.*?)\]$/', trim($prev), $m)) {
preg_match_all('/\((.*?)\)/', $m[1], $tjs);
$tjX = $x;
foreach ($tjs[1] as $tj) {
self::addText($texts, $tj, $tjX, $y);
$tjX += 10; // Décalage estimé pour les mots suivants du tableau
}
}
}
}
}
}
if (empty($texts)) throw new Exception("Impossible de lire le contenu du PDF. Le fichier est peut-être crypté ou corrompu.");
// 2. Définition des Colonnes Verticles (X) via les En-têtes de Jour
$dayLabels = ['LUNDI', 'MARDI', 'MERCREDI', 'JEUDI', 'VENDREDI'];
$colXs = [];
foreach ($texts as $t) {
$clean = strtoupper(preg_replace('/[^A-Z]/', '', $t['text']));
if (in_array($clean, $dayLabels)) {
$colXs[$clean] = $t['x'];
}
}
// Sécurité si les jours exacts ne sont pas détectés à cause de la police
if (count($colXs) < 5) {
$allXs = array_column($texts, 'x');
sort($allXs);
$minX = $allXs[0] ?? 0;
$maxX = end($allXs) ?? 500;
$step = ($maxX - $minX) / 5;
$colXs = [
'LUNDI' => $minX + $step * 0.5,
'MARDI' => $minX + $step * 1.5,
'MERCREDI' => $minX + $step * 2.5,
'JEUDI' => $minX + $step * 3.5,
'VENDREDI' => $minX + $step * 4.5,
];
}
$sortedXs = array_values($colXs);
sort($sortedXs);
// 3. Définition des Tranches Horizontales (Y) via les Titres de Rangée (Zone Extrême Gauche)
$rowHeaders = [];
foreach ($texts as $t) {
$clean = strtoupper(preg_replace('/[^A-Z]/', '', $t['text']));
if (in_array($clean, ['HORSDEUVRE', 'DECLINAISON', 'PLATCHAUD', 'GARNITURE', 'PRODUITLAITIER', 'DESSERT', 'LESGOUTERS'])) {
// On s'assure de ne capter que la colonne tout à gauche
if ($t['x'] < ($sortedXs[0] - 20)) {
$rowHeaders[] = ['label' => $clean, 'y' => $t['y']];
}
}
}
// Tri de haut en bas (Y du PDF est décroissant du haut vers le bas)
usort($rowHeaders, fn($a, $b) => $b['y'] <=> $a['y']);
$yPlatTop = null; $yPlatBot = null;
$yGarTop = null; $yGarBot = null;
for ($i = 0; $i < count($rowHeaders); $i++) {
if ($rowHeaders[$i]['label'] === 'PLATCHAUD') {
$yPlatTop = $rowHeaders[$i]['y'];
// La limite basse est le label suivant, ou -50 unités
$yPlatBot = $rowHeaders[$i+1]['y'] ?? ($yPlatTop - 50);
}
if ($rowHeaders[$i]['label'] === 'GARNITURE') {
$yGarTop = $rowHeaders[$i]['y'];
$yGarBot = $rowHeaders[$i+1]['y'] ?? ($yGarTop - 50);
}
}
if (!$yPlatTop || !$yGarTop) {
throw new Exception("Structure du menu non reconnue (Mots-clés 'PLAT CHAUD' et 'GARNITURE' introuvables).");
}
// 4. Affectation Chirurgicale par Intersection Grille (X, Y)
$days = [[], [], [], [], []];
foreach ($texts as $t) {
$cleanAlpha = strtoupper(preg_replace('/[^A-Z]/', '', $t['text']));
// Rejet des labels génériques et de la fameuse ligne "Déclinaison"
if (in_array($cleanAlpha, ['DECLINAISON', 'MENUVEGETARIEN', 'PLATCHAUD', 'GARNITURE', 'PRODUITLAITIER', 'HORSDEUVRE', 'LESGOUTERS', 'DESSERT'])) continue;
// Retrait des labels BIO/HVE et mentions superflues
$textVal = trim(preg_replace('/\b(AB|HVE|MSC|Label Rouge)\b/i', '', $t['text']));
$textVal = str_replace(['(plat', 'complet)'], '', $textVal);
$textVal = trim(preg_replace('/[^a-zA-ZÀ-ÿ0-9\s&\-]/u', '', $textVal));
if (strlen($textVal) < 3) continue;
// Filtre Vertical : Est-on dans la tranche Plat Chaud ou Garniture ? (Tolérance +/- 5 unités)
$inPlat = ($t['y'] <= $yPlatTop + 5 && $t['y'] >= $yPlatBot + 5);
$inGar = ($t['y'] <= $yGarTop + 5 && $t['y'] >= $yGarBot + 5);
if ($inPlat || $inGar) {
// Filtre Horizontal : Quel est le jour le plus proche ?
$bestCol = 0;
$minDiff = PHP_FLOAT_MAX;
foreach ($sortedXs as $idx => $cx) {
$diff = abs($t['x'] - $cx);
if ($diff < $minDiff) {
$minDiff = $diff;
$bestCol = $idx;
}
}
$days[$bestCol][] = ['text' => $textVal, 'y' => $t['y'], 'type' => $inPlat ? 'plat' : 'gar'];
}
}
// 5. Concaténation Finale
$finalDays = [];
for ($i = 0; $i < 5; $i++) {
$plats = array_filter($days[$i], fn($item) => $item['type'] === 'plat');
$gars = array_filter($days[$i], fn($item) => $item['type'] === 'gar');
usort($plats, fn($a, $b) => $b['y'] <=> $a['y']);
usort($gars, fn($a, $b) => $b['y'] <=> $a['y']);
$platText = self::concatLines($plats);
$garText = self::concatLines($gars);
$full = trim("$platText + $garText", " +");
$finalDays[] = preg_replace('/\s+/', ' ', $full);
}
return $finalDays;
}
private static function concatLines($items) {
$lines = [];
$currentLine = "";
$lastY = null;
foreach ($items as $item) {
// Si le décalage Y est trop fort, on considère que c'est une nouvelle ligne
if ($lastY !== null && abs($lastY - $item['y']) > 8) {
if ($currentLine) $lines[] = trim($currentLine);
$currentLine = $item['text'];
} else {
$currentLine .= " " . $item['text'];
}
$lastY = $item['y'];
}
if ($currentLine) $lines[] = trim($currentLine);
return implode(' ', array_filter($lines, fn($l) => strlen(trim($l)) > 2));
}
private static function addText(&$texts, $rawText, $x, $y) {
// Décodage de l'octal \123
$m = preg_replace_callback('/\\\\([0-7]{1,3})/', function($cb) { return chr(octdec($cb[1])); }, $rawText);
// Détection de l'UTF-16BE (Le BOM \xFE\xFF indique que le texte est double encodé)
if (str_starts_with($m, "\xFE\xFF")) {
$m = mb_convert_encoding(substr($m, 2), 'UTF-8', 'UTF-16BE');
} else {
// Fallback Latin-1 si pas de BOM
$m = mb_convert_encoding($m, 'UTF-8', 'ISO-8859-1');
}
// Suppression radicale des octets nuls responsables de l'espacement forcé (E s c a l o p e)
$m = str_replace("\0", "", $m);
$m = trim(preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\+&\'’\-]/u', '', $m));
if ($m !== '') {
$texts[] = ['text' => $m, 'x' => $x, 'y' => $y];
}
}
}
try {
$filename = $_FILES['pdf_file']['name'];
// 1. Extraction robuste de la date depuis le nom de fichier (Menus-du-14-au-20-septembre-2026.pdf)
$mondayDate = null;
if (preg_match('/(\d{1,2})[\s-]+au[\s-]+\d{1,2}[\s-]+([a-zèéû]+)[\s-]+(\d{4})/i', $filename, $m)) {
$monthsMap = ['janvier'=>'01','fevrier'=>'02','février'=>'02','mars'=>'03','avril'=>'04','mai'=>'05','juin'=>'06','juillet'=>'07','aout'=>'08','août'=>'08','septembre'=>'09','octobre'=>'10','novembre'=>'11','decembre'=>'12','décembre'=>'12'];
$month = $monthsMap[strtolower($m[2])] ?? '09';
$mondayDate = $m[3] . '-' . $month . '-' . str_pad($m[1], 2, '0', STR_PAD_LEFT);
}
if (!$mondayDate) {
echo json_encode(['success' => false, 'error' => 'Veuillez nommer le fichier "Menus-du-14-au-20-septembre-2026.pdf".']);
exit;
}
// 2. Lancement de l'Analyse Spatiale
$plats = NativePdfParser::getMealData($_FILES['pdf_file']['tmp_name']);
if (empty($plats) || count($plats) !== 5) {
echo json_encode(['success' => false, 'error' => 'Erreur lors du découpage des 5 colonnes.']);
exit;
}
// 3. Insertion en BDD (Lundi au Vendredi)
$pdo->beginTransaction();
$stmt = $pdo->prepare("
INSERT INTO pf_meals_plan (plan_date, service, person_id, meal_name)
VALUES (?, 'lunch', ?, ?)
ON DUPLICATE KEY UPDATE meal_name = VALUES(meal_name)
");
$currentDate = new DateTime($mondayDate);
for ($i = 0; $i < 5; $i++) {
$fullMeal = $plats[$i];
if (!empty($fullMeal) && $fullMeal !== '+') {
$stmt->execute([$currentDate->format('Y-m-d'), $personId, $fullMeal]);
}
$currentDate->modify('+1 day');
}
$pdo->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+56
View File
@@ -0,0 +1,56 @@
<?php
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'error' => 'Méthode non autorisée']);
exit;
}
$action = $_POST['action'] ?? '';
try {
if ($action === 'save_cell') {
$date = $_POST['plan_date'];
$service = $_POST['service'];
$person_id = (int)$_POST['person_id'];
$meal_name = trim($_POST['meal_name']);
if (empty($meal_name)) {
$stmt = $pdo->prepare("DELETE FROM pf_meals_plan WHERE plan_date=? AND service=? AND person_id=?");
$stmt->execute([$date, $service, $person_id]);
} else {
$stmt = $pdo->prepare("INSERT INTO pf_meals_plan (plan_date, service, person_id, meal_name) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE meal_name = VALUES(meal_name)");
$stmt->execute([$date, $service, $person_id, $meal_name]);
}
echo json_encode(['success' => true]);
exit;
}
if ($action === 'save_bulk') {
$updates = json_decode($_POST['updates'], true);
if (is_array($updates)) {
$pdo->beginTransaction();
$stmtDel = $pdo->prepare("DELETE FROM pf_meals_plan WHERE plan_date=? AND service=? AND person_id=?");
$stmtIns = $pdo->prepare("INSERT INTO pf_meals_plan (plan_date, service, person_id, meal_name) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE meal_name = VALUES(meal_name)");
foreach ($updates as $u) {
if (empty(trim($u['meal_name']))) {
$stmtDel->execute([$u['plan_date'], $u['service'], $u['person_id']]);
} else {
$stmtIns->execute([$u['plan_date'], $u['service'], $u['person_id'], trim($u['meal_name'])]);
}
}
$pdo->commit();
}
echo json_encode(['success' => true]);
exit;
}
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
@@ -1,5 +1,53 @@
/* Liste module JS */
const API = '/modules/liste/api.php';
<?php
// modules/food/views/liste.php
if (!isset($pdo)) { exit; }
?>
<div class="liste-main">
<!-- Tabs des listes -->
<div class="liste-tabs-bar">
<div class="liste-tabs" id="liste-tabs"></div>
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
</div>
<!-- Corps de la liste active -->
<div class="liste-body" id="liste-body">
<div class="liste-add-card">
<input type="text" id="liste-input" maxlength="500" placeholder="<?= htmlspecialchars(tr('liste_placeholder_add')) ?>" autocomplete="off">
<button id="btn-liste-add"><?= htmlspecialchars(tr('liste_btn_add')) ?></button>
</div>
<div class="liste-toolbar" id="liste-toolbar">
<button class="btn-tool" id="btn-uncheck-all"><?= htmlspecialchars(tr('liste_btn_uncheck_all')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-delete-picked"><?= htmlspecialchars(tr('liste_btn_delete_picked')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-clear-all"><?= htmlspecialchars(tr('liste_btn_clear_all')) ?></button>
</div>
<div id="liste-items-root"></div>
<div id="liste-history-root"></div>
</div>
</div>
<script>
const LISTE_TRANSLATIONS = {
already_in: <?= json_encode(tr('liste_already_in')) ?>,
confirm_clear: <?= json_encode(tr('liste_confirm_clear')) ?>,
confirm_delete_list: <?= json_encode(tr('liste_confirm_delete_list')) ?>,
rename_list: <?= json_encode(tr('liste_rename_list')) ?>,
new_name: <?= json_encode(tr('liste_new_name')) ?>,
list_deleted: <?= json_encode(tr('liste_list_deleted')) ?>,
list_created: <?= json_encode(tr('liste_list_created')) ?>,
list_renamed: <?= json_encode(tr('liste_list_renamed')) ?>,
edit_placeholder: <?= json_encode(tr('liste_edit_placeholder')) ?>,
history_title: <?= json_encode(tr('liste_history_title')) ?>,
added: <?= json_encode(tr('liste_added')) ?>,
updated: <?= json_encode(tr('liste_updated')) ?>,
deleted: <?= json_encode(tr('liste_deleted')) ?>,
cancel: <?= json_encode(tr('cancel')) ?>,
};
// ⚠️ NOUVEAU CHEMIN D'API
const API = '/modules/food/includes/api/api-liste.php';
const T = window.LISTE_TRANSLATIONS || {};
const state = {
@@ -515,3 +563,5 @@ async function init() {
}
init();
</script>
+357
View File
@@ -0,0 +1,357 @@
<?php
// modules/meals/main.php
// Récupération stricte des membres du foyer (Exclusion des intervenants et proches)
$stmt = $pdo->query("SELECT id, name FROM pf_people WHERE role NOT IN ('helper', 'nounou', 'proche_adulte', 'proche_enfant', 'relative') AND is_active = 1 ORDER BY id ASC");
$members = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($members)) {
echo "<div class='pf-alert pf-alert--warning'>" . tr('meals_no_members') . "</div>";
return;
}
// Calcul des 14 jours (Lundi semaine courante -> Dimanche semaine prochaine)
$start_date = new DateTime();
$start_date->modify('monday this week');
$dates = [];
for ($i = 0; $i < 14; $i++) {
$d = clone $start_date;
$d->modify("+$i days");
$dates[] = $d->format('Y-m-d');
}
$stmtMeals = $pdo->prepare("SELECT plan_date, service, person_id, meal_name FROM pf_meals_plan WHERE plan_date BETWEEN ? AND ?");
$stmtMeals->execute([$dates[0], end($dates)]);
$mealsData = [];
while ($row = $stmtMeals->fetch(PDO::FETCH_ASSOC)) {
$mealsData[$row['plan_date']][$row['service']][$row['person_id']] = $row['meal_name'];
}
?>
<div class="pf-container meals-container">
<div class="meals-header-bar">
<h1 class="meals-title">
<span class="meals-title-icon">🍽️</span>
<span data-i18n="meals_board_title"><?= tr('meals_board_title') ?></span>
</h1>
<div class="meals-actions">
<!-- NOUVEAU : Bouton Import PDF -->
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('importMenuModal').classList.add('open'); document.body.classList.add('no-scroll');">
📄 <?= tr('meal_btn_import_pdf') ?>
</button>
</div>
</div>
<!-- Barre contextuelle (Fixe en bas) -->
<div class="context-toolbar" id="contextToolbar">
<button class="ctx-btn" onmousedown="event.preventDefault(); applyToAllInRow()">➡️ <span data-i18n="btn_apply_all"><?= tr('btn_apply_all') ?></span></button>
<button class="ctx-btn primary" onmousedown="event.preventDefault(); openCopySheetFromCell()">🔄 <span data-i18n="btn_copy_to"><?= tr('btn_copy_to') ?></span></button>
</div>
<div class="meals-wrapper">
<?php
$weeks = array_chunk($dates, 7);
foreach ($weeks as $weekIndex => $weekDates):
$startWeekStr = date('d/m', strtotime($weekDates[0]));
$endWeekStr = date('d/m', strtotime(end($weekDates)));
?>
<h2 class="meals-week-title"><?= tr('meal_week') ?> (<?= $startWeekStr ?> - <?= $endWeekStr ?>)</h2>
<div class="meals-table-container">
<table class="meals-table" id="mealsTable_<?= $weekIndex ?>">
<thead>
<tr>
<th class="col-sticky"></th>
<?php foreach ($members as $m): ?>
<th><?= htmlspecialchars(strtoupper($m['name'])) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($weekDates as $dateStr):
$dayKey = strtolower(date('D', strtotime($dateStr)));
$dayName = tr('day_' . $dayKey);
$dayDate = date('d/m', strtotime($dateStr));
$fullDayName = tr('day_full_' . $dayKey) . ' ' . $dayDate;
?>
<tr class="row-lunch">
<td class="col-sticky">
<span class="day-label"><?= $dayName ?> <?= $dayDate ?></span>
<div class="service-icon" onmousedown="event.preventDefault(); openCopySheetFromRow('<?= $dateStr ?>', 'lunch', '<?= addslashes($fullDayName) ?>')" title="<?= tr('meals_copy_lunch') ?>">☀️</div>
</td>
<?php foreach ($members as $m):
$val = $mealsData[$dateStr]['lunch'][$m['id']] ?? '';
?>
<td class="meal-input-cell">
<textarea class="meal-input js-meal-input" data-d="<?= $dateStr ?>" data-s="lunch" data-p="<?= $m['id'] ?>" placeholder="-"><?= htmlspecialchars($val) ?></textarea>
</td>
<?php endforeach; ?>
</tr>
<tr class="row-dinner">
<td class="col-sticky">
<span class="day-label invisible"><?= $dayName ?> <?= $dayDate ?></span>
<div class="service-icon" onmousedown="event.preventDefault(); openCopySheetFromRow('<?= $dateStr ?>', 'dinner', '<?= addslashes($fullDayName) ?>')" title="<?= tr('meals_copy_dinner') ?>">🌙</div>
</td>
<?php foreach ($members as $m):
$val = $mealsData[$dateStr]['dinner'][$m['id']] ?? '';
?>
<td class="meal-input-cell">
<textarea class="meal-input js-meal-input" data-d="<?= $dateStr ?>" data-s="dinner" data-p="<?= $m['id'] ?>" placeholder="-"><?= htmlspecialchars($val) ?></textarea>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Bottom Sheet de Copie (Format Grille Ultra-compacte) -->
<div id="copyModal" class="pf-modal pf-bottom-sheet">
<div class="pf-modal-content pf-bs-content copy-modal-large">
<div class="bs-header">
<h3 class="bs-title">🔄 <span data-i18n="modal_copy_title"><?= tr('modal_copy_title') ?></span></h3>
<button class="bs-close" onclick="closeCopyModal()">&times;</button>
</div>
<div id="copySourceLabel" class="meals-copy-source">Source : ...</div>
<div class="week-section-title" data-i18n="week_current"><?= tr('week_current') ?></div>
<div class="copy-days-grid">
<?php for($i=0; $i<7; $i++): $dStr = $dates[$i]; $dName = tr('day_full_' . strtolower(date('D', strtotime($dStr)))) . ' ' . date('d/m', strtotime($dStr)); ?>
<div class="copy-day-card">
<div class="copy-day-name"><?= $dName ?></div>
<div class="copy-day-actions">
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'lunch')" title="<?= tr('meal_lunch') ?>">☀️</button>
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'dinner')" title="<?= tr('meal_dinner') ?>">🌙</button>
</div>
</div>
<?php endfor; ?>
</div>
<div class="week-section-title week-section-mt" data-i18n="week_next"><?= tr('week_next') ?></div>
<div class="copy-days-grid">
<?php for($i=7; $i<14; $i++): $dStr = $dates[$i]; $dName = tr('day_full_' . strtolower(date('D', strtotime($dStr)))) . ' ' . date('d/m', strtotime($dStr)); ?>
<div class="copy-day-card">
<div class="copy-day-name"><?= $dName ?></div>
<div class="copy-day-actions">
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'lunch')" title="<?= tr('meal_lunch') ?>">☀️</button>
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'dinner')" title="<?= tr('meal_dinner') ?>">🌙</button>
</div>
</div>
<?php endfor; ?>
</div>
</div>
</div>
<!-- NOUVEAU : Modale d'import PDF Cantine -->
<div id="importMenuModal" class="pf-modal">
<div class="pf-modal-content">
<div class="pf-modal-header" style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-light); padding-bottom: 15px; margin-bottom: 20px;">
<h3 class="pf-modal-title" style="margin: 0; border: none; padding: 0;">📄 <?= tr('meal_import_title') ?></h3>
<button type="button" class="pf-modal-close" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--text-muted);" onclick="document.getElementById('importMenuModal').classList.remove('open'); document.body.classList.remove('no-scroll');">&times;</button>
</div>
<form id="importMenuForm" onsubmit="importPdfMenu(event)" action="/modules/food/includes/api/import-pdf.php" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<div class="pf-form-group">
<label class="pf-label"><?= tr('meal_import_child') ?></label>
<select name="person_id" class="pf-input" required>
<option value="" disabled selected>-- <?= tr('meal_select_child') ?> --</option>
<?php foreach ($members as $m): ?>
<option value="<?= $m['id'] ?>"><?= htmlspecialchars($m['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="pf-form-group">
<label class="pf-label"><?= tr('meal_pdf_file') ?></label>
<div class="meal-dropzone" onclick="document.getElementById('canteenPdf').click()">
<span class="meal-dropzone-icon">📥</span>
<span id="pdfFileName"><?= tr('meal_drop_pdf') ?></span>
<input type="file" id="canteenPdf" name="pdf_file" accept=".pdf" style="display: none;" required onchange="document.getElementById('pdfFileName').innerText = this.files[0] ? this.files[0].name : '<?= addslashes(tr('meal_drop_pdf')) ?>';">
</div>
</div>
<div class="modal-footer">
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('importMenuModal').classList.remove('open'); document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn" id="btnImportPdf"><?= tr('btn_import') ?></button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Sauvegarde automatique AJAX à chaque modification
document.querySelectorAll('.js-meal-input').forEach(input => {
input.addEventListener('change', async function() {
const fd = new FormData();
fd.append('action', 'save_cell');
fd.append('plan_date', this.dataset.d);
fd.append('service', this.dataset.s);
fd.append('person_id', this.dataset.p);
fd.append('meal_name', this.value);
try {
await pachaFetch('/modules/food/includes/api/save-meals.php', { method: 'POST', body: fd });
flashSuccess(this);
} catch(e) {
console.error("Save error", e);
}
});
});
});
// Focus Context Bar
let copyState = { type: null, sourceDate: null, service: null, personIdx: null, sourceElements: [], fullDayName: '' };
document.addEventListener('focusin', (e) => {
if(e.target.classList.contains('js-meal-input')) {
copyState = {
type: 'cell',
sourceDate: e.target.getAttribute('data-d'),
service: e.target.getAttribute('data-s'),
personIdx: e.target.getAttribute('data-p'),
sourceElements: [e.target],
fullDayName: ''
};
document.getElementById('contextToolbar').classList.add('active');
}
});
document.addEventListener('focusout', (e) => {
setTimeout(() => {
if(document.activeElement && !document.activeElement.classList.contains('js-meal-input')) {
document.getElementById('contextToolbar').classList.remove('active');
}
}, 150);
});
// Actions Toolbar
window.applyToAllInRow = async function() {
if(copyState.type !== 'cell' || !copyState.sourceElements[0].value.trim()) return;
const val = copyState.sourceElements[0].value;
const inputs = document.querySelectorAll(`.js-meal-input[data-d="${copyState.sourceDate}"][data-s="${copyState.service}"]`);
let updates = [];
inputs.forEach(inp => {
if(inp !== copyState.sourceElements[0]) {
inp.value = val;
flashSuccess(inp);
updates.push({ plan_date: inp.dataset.d, service: inp.dataset.s, person_id: inp.dataset.p, meal_name: val });
}
});
if (updates.length > 0) {
const fd = new FormData();
fd.append('action', 'save_bulk');
fd.append('updates', JSON.stringify(updates));
await pachaFetch('/modules/food/includes/api/save-meals.php', { method: 'POST', body: fd });
}
};
window.openCopySheetFromCell = function() {
if(!copyState.sourceElements[0] || !copyState.sourceElements[0].value.trim()) return;
if(document.activeElement) document.activeElement.blur();
const svcLabel = copyState.service === 'lunch' ? (window.I18N['meal_lunch'] || 'Midi') : (window.I18N['meal_dinner'] || 'Soir');
const dObj = new Date(copyState.sourceDate);
const dayKey = dObj.toLocaleDateString('en-US', {weekday: 'short'}).toLowerCase();
const dayName = window.I18N['day_full_' + dayKey] || dObj.toLocaleDateString();
document.getElementById('copySourceLabel').innerText = `Source : ${window.I18N['source_individual'] || 'Individuel'} (${dayName} ${svcLabel})`;
document.getElementById('copyModal').classList.add('open');
document.body.classList.add('no-scroll');
};
window.openCopySheetFromRow = function(dateStr, service, fullDayName) {
const inputs = document.querySelectorAll(`.js-meal-input[data-d="${dateStr}"][data-s="${service}"]`);
if (!Array.from(inputs).some(inp => inp.value.trim() !== '')) return;
copyState = { type: 'row', sourceDate: dateStr, service: service, sourceElements: Array.from(inputs), fullDayName: fullDayName };
const svcLabel = service === 'lunch' ? (window.I18N['meal_lunch'] || 'Midi') : (window.I18N['meal_dinner'] || 'Soir');
document.getElementById('copySourceLabel').innerText = `Source : ${window.I18N['source_row'] || 'Repas commun'} (${fullDayName} ${svcLabel})`;
document.getElementById('copyModal').classList.add('open');
document.body.classList.add('no-scroll');
};
window.closeCopyModal = function() {
document.getElementById('copyModal').classList.remove('open');
document.body.classList.remove('no-scroll');
};
window.executeCopy = async function(targetDate, targetService) {
let updates = [];
if(copyState.type === 'cell') {
const val = copyState.sourceElements[0].value;
const targetInput = document.querySelector(`.js-meal-input[data-d="${targetDate}"][data-s="${targetService}"][data-p="${copyState.personIdx}"]`);
if(targetInput) {
targetInput.value = val;
flashSuccess(targetInput);
updates.push({ plan_date: targetDate, service: targetService, person_id: copyState.personIdx, meal_name: val });
}
} else if (copyState.type === 'row') {
const targetInputs = document.querySelectorAll(`.js-meal-input[data-d="${targetDate}"][data-s="${targetService}"]`);
copyState.sourceElements.forEach((inp, idx) => {
if(inp.value.trim() !== '') {
targetInputs[idx].value = inp.value;
flashSuccess(targetInputs[idx]);
updates.push({ plan_date: targetDate, service: targetService, person_id: targetInputs[idx].dataset.p, meal_name: inp.value });
}
});
}
closeCopyModal();
if (updates.length > 0) {
const fd = new FormData();
fd.append('action', 'save_bulk');
fd.append('updates', JSON.stringify(updates));
await pachaFetch('/modules/meals/includes/api/save-meals.php', { method: 'POST', body: fd });
}
};
function flashSuccess(input) {
input.style.transition = 'background 0.3s';
input.style.background = 'var(--alert-success-bg)';
setTimeout(() => input.style.background = 'transparent', 400);
}
// NOUVEAU : Fonction Javascript d'Import PDF
window.importPdfMenu = async function(e) {
e.preventDefault();
const form = e.target;
const btn = document.getElementById('btnImportPdf');
const oldText = btn.innerText;
btn.disabled = true;
btn.innerText = '⏳...';
const fd = new FormData(form);
const actionUrl = form.getAttribute('action');
try {
const res = await pachaFetch(actionUrl, { method: 'POST', body: fd });
if (res && res.success) {
document.getElementById('importMenuModal').classList.remove('open');
document.body.classList.remove('no-scroll');
if (window.showToast) showToast(window.I18N['meal_import_success'] || 'Menus importés !', 'success');
setTimeout(() => window.location.reload(), 1000);
} else {
if (window.showToast) showToast(res.error || window.I18N['error_occured'], 'error');
else alert(res.error);
}
} catch(err) {
if (window.showToast) showToast(window.I18N['error_occured'], 'error');
} finally {
btn.disabled = false;
btn.innerText = oldText;
form.reset();
document.getElementById('pdfFileName').innerText = window.I18N['meal_drop_pdf'] || 'Cliquer pour ajouter le PDF';
}
};
</script>
+1003 -1089
View File
File diff suppressed because it is too large. Load diff
+1268 -333
View File
File diff suppressed because it is too large. Load diff
@@ -7,11 +7,20 @@ require_login();
// ---------------------------------------------------------------------------
// INTERCEPTIONS AJAX (Exécution ultra rapide sans rechargement lourd)
// ---------------------------------------------------------------------------
if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration', 'add_single_item'])) {
if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration', 'add_single_item', 'update_holiday_note'])) {
header('Content-Type: application/json');
session_write_close(); // 🚀 LIBÈRE LA SESSION : Permet d'autres requêtes simultanées sans bloquer le navigateur
session_write_close();
try {
if ($_POST['action'] === 'update_holiday_note') {
$hId = (int)$_POST['holiday_id'];
$notes = $_POST['notes'] ?? '';
$stmt = $pdo->prepare("UPDATE pf_holidays SET notes = ? WHERE id = ?");
$stmt->execute([$notes, $hId]);
echo json_encode(['success' => true]);
exit;
}
if ($_POST['action'] === 'add_single_item') {
$holiday_id = (int)$_POST['holiday_id'];
$sort_order = (int)$_POST['sort_order'];
@@ -25,16 +34,43 @@ if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime
$date = !empty($_POST['item_date']) ? $_POST['item_date'] : null;
$time = !empty($_POST['item_time']) ? $_POST['item_time'] : null;
// Récupération sécurisée du contexte
$context = !empty($_POST['expense_context']) ? $_POST['expense_context'] : (!empty($_POST['context']) ? $_POST['context'] : 'local');
$amount = (float)$_POST['amount'];
$name = $_POST['name'] ?? 'Trajet';
$category = $_POST['category'] ?? 'transport';
$pdo->beginTransaction();
// 🛡️ ANTI-DOUBLON ABSOLU
if ($context === 'transit') {
$stmtCheck = $pdo->prepare("SELECT id FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ? AND expense_context = 'transit'");
$stmtCheck->execute([$holiday_id, $sort_order]);
$existingId = $stmtCheck->fetchColumn();
if ($existingId) {
// Mise à jour de la ligne existante
$stmtUp = $pdo->prepare("UPDATE pf_holidays_items SET item_date = ?, item_time = ?, amount = ?, duration = ?, name = ? WHERE id = ?");
$stmtUp->execute([$date, $time, $amount, $dur, $name, $existingId]);
$pdo->commit();
echo json_encode(['success' => true, 'id' => $existingId]);
exit;
}
}
// Insertion si aucun doublon n'existe
$ins = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng, sort_order, step_start_date, step_end_date, step_type, expense_context, duration, item_date, item_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$ins->execute([
$holiday_id, $_POST['category'], $_POST['name'], (float)$_POST['amount'], 0,
$holiday_id, $category, $name, $amount, 0,
$stepInfo['location_name'], $stepInfo['lat'], $stepInfo['lng'],
$sort_order, $stepInfo['step_start_date'], $stepInfo['step_end_date'],
$stepInfo['step_type'], $_POST['context'], $dur, $date, $time
$stepInfo['step_type'], $context, $dur, $date, $time
]);
$newId = $pdo->lastInsertId();
$pdo->commit();
echo json_encode(['success' => true]);
echo json_encode(['success' => true, 'id' => $newId]);
exit;
}
echo json_encode(['success' => false, 'error' => 'Etape introuvable']);
@@ -61,6 +97,7 @@ if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime
echo json_encode(['success' => true]);
exit;
}
} catch (Exception $e) {
if ($pdo->inTransaction()) { $pdo->rollBack(); }
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
@@ -92,10 +129,19 @@ if ($holiday_id > 0 && !empty($location_name)) {
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]);
$target_order = $old_sort_order;
} else {
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
$stmtMax->execute([$holiday_id]);
$max = $stmtMax->fetchColumn();
$target_order = ($max !== null) ? (int)$max + 1 : 0;
// NOUVEAU : Logique d'intercalage d'étape
$insert_after = $_POST['insert_after'] ?? 'end';
if ($insert_after === 'end') {
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
$stmtMax->execute([$holiday_id]);
$max = $stmtMax->fetchColumn();
$target_order = ($max !== null) ? (int)$max + 1 : 0;
} else {
$target_order = (int)$insert_after + 1;
// On décale toutes les étapes suivantes vers le bas
$pdo->prepare("UPDATE pf_holidays_items SET sort_order = sort_order + 1 WHERE holiday_id = ? AND sort_order >= ?")->execute([$holiday_id, $target_order]);
}
}
$step_start = !empty($_POST['step_start_date']) ? $_POST['step_start_date'] : null;
@@ -124,7 +170,11 @@ if ($holiday_id > 0 && !empty($location_name)) {
$dur = !empty($_POST['items']['duration'][$i]) ? (int)$_POST['items']['duration'][$i] : 1;
$context = !empty($_POST['items']['context'][$i]) ? $_POST['items']['context'][$i] : 'local';
$stmt->execute([$holiday_id, $cat, $name, $amount, $paid, $location_name, $lat, $lng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $step_type, $context]);
// Récupération des coordonnées spécifiques à l'activité (ou fallback sur l'étape)
$itemLat = !empty($_POST['items']['lat'][$i]) ? (float)$_POST['items']['lat'][$i] : $lat;
$itemLng = !empty($_POST['items']['lng'][$i]) ? (float)$_POST['items']['lng'][$i] : $lng;
$stmt->execute([$holiday_id, $cat, $name, $amount, $paid, $location_name, $itemLat, $itemLng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $step_type, $context]);
$validItemsCount++;
}
}
+41 -6
View File
@@ -28,16 +28,51 @@ $vehicle_id = !empty($_POST['vehicle_id']) ? (int)$_POST['vehicle_id'] : null;
try {
$pdo->beginTransaction();
// 1. Détection d'un changement de nom pour refaire l'appel API
$image_url = null;
$old_title = null;
if ($id) {
// UPDATE (avec vehicle_id)
$sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=?, vehicle_id=? WHERE id=?";
$stmtOld = $pdo->prepare("SELECT title, image_url FROM pf_holidays WHERE id = ?");
$stmtOld->execute([$id]);
if ($row = $stmtOld->fetch()) {
$old_title = $row['title'];
$image_url = $row['image_url'];
}
}
// 2. Appel à Pixabay si nouveau voyage ou si le titre a changé
if (!$id || strcasecmp($old_title ?? '', $title) !== 0) {
$apiKey = '56931941-a86fbea4e14b88712cc1e9ed9';
$query = urlencode($title);
// On force des photos horizontales HD
$url = "https://pixabay.com/api/?key={$apiKey}&q={$query}&image_type=photo&orientation=horizontal&min_width=1280&per_page=3";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 3); // Timeout de 3s max pour ne pas bloquer l'UI
$response = curl_exec($ch);
curl_close($ch);
if ($response) {
$data = json_decode($response, true);
if (!empty($data['hits'][0]['largeImageURL'])) {
$image_url = $data['hits'][0]['largeImageURL'];
}
}
}
// 3. Sauvegarde en Base
if ($id) {
// UPDATE
$sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=?, vehicle_id=?, image_url=? WHERE id=?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $id]);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $image_url, $id]);
} else {
// INSERT (avec vehicle_id)
$sql = "INSERT INTO pf_holidays (title, period_hint, start_date, end_date, status, budget_food, budget_extra, notes, vehicle_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
// INSERT
$sql = "INSERT INTO pf_holidays (title, period_hint, start_date, end_date, status, budget_food, budget_extra, notes, vehicle_id, image_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id]);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $image_url]);
$id = $pdo->lastInsertId();
}
File diff suppressed because it is too large. Load diff
+5 -3
View File
@@ -122,9 +122,10 @@ window.closeHolidayModal = window.closeHolidayModal || function() {
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll');
};
</script>
<script src="/modules/holidays/holidays.js"></script>
<?php
function renderHolidayCard($h, $pdo) {
@@ -136,8 +137,9 @@ function renderHolidayCard($h, $pdo) {
$dateDisplay = htmlspecialchars($h['period_hint'] ?? '');
if (empty($dateDisplay) && $h['start_date']) {
$dateDisplay = date('d/m/Y', strtotime($h['start_date']));
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($h['end_date']));
// 💡 Format Jour/Mois uniquement
$dateDisplay = date('d/m', strtotime($h['start_date']));
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m', strtotime($h['end_date']));
}
$statusClass = match($h['status']) {
+13 -19
View File
@@ -1,5 +1,6 @@
<div id="holidayModal" class="pf-modal">
<div class="pf-modal-content hol-modal-content" style="max-width: 500px;"> <h3 id="modalTitle" class="pf-modal-title"><?= tr('hdl_modal_title') ?></h3>
<div class="pf-modal-content hol-modal-content" style="max-width: 500px;">
<h3 id="modalTitle" class="pf-modal-title"><?= tr('hdl_modal_title') ?></h3>
<form action="/modules/holidays/includes/api/save_holiday.php" method="POST" id="holidayForm">
<input type="hidden" name="id" id="inp_id">
@@ -23,23 +24,21 @@
</div>
<div class="form-row">
<div>
<div class="hol-flex-1">
<label class="pf-label"><?= tr('hdl_label_period') ?></label>
<input type="text" name="period_hint" id="inp_period" class="pf-input" placeholder="<?= tr('hdl_ph_period') ?>">
</div>
<div class="hol-date-range-group">
<div class="hol-flex-1">
<label class="pf-label"><?= tr('hdl_label_from') ?></label>
<input type="date" name="start_date" id="inp_start" class="pf-input">
</div>
<div class="hol-flex-1">
<label class="pf-label"><?= tr('hdl_label_to') ?></label>
<input type="date" name="end_date" id="inp_end" class="pf-input">
</div>
<!-- DATES AVEC FLATPICKR -->
<div class="hol-flex-2">
<label class="pf-label">📅 Dates du voyage</label>
<input type="text" id="hol_date_range" class="pf-input" placeholder="Sélectionnez les dates..." readonly style="cursor: pointer; background-color: var(--bg-panel); color: var(--primary); font-weight: bold; text-align: center; letter-spacing: 0.5px;">
<!-- Vraies données pour save_holiday.php -->
<input type="hidden" name="start_date" id="inp_start">
<input type="hidden" name="end_date" id="inp_end">
</div>
</div>
<div class="form-group" style="margin-top: 15px; padding: 10px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;">
<div class="form-group" style="margin-top: 15px; padding: 10px; background: var(--bg-subtle); border-radius: 8px; border: 1px solid var(--border-light);">
<label class="pf-label" style="margin-bottom: 5px;">🚗 Véhicule utilisé (Optionnel)</label>
<select name="vehicle_id" id="inp_vehicle_id" class="pf-input">
<option value="">-- Aucun / Autre transport --</option>
@@ -52,21 +51,16 @@
<hr class="hol-divider">
<div class="form-row">
<div>
<div class="hol-flex-1">
<label class="pf-label">🍔 <?= tr('hdl_label_budget_food') ?></label>
<input type="number" step="0.01" name="budget_food" id="inp_food" class="pf-input" placeholder="0.00">
</div>
<div>
<div class="hol-flex-1">
<label class="pf-label">🎁 <?= tr('hdl_label_budget_extras') ?></label>
<input type="number" step="0.01" name="budget_extra" id="inp_extra" class="pf-input" placeholder="0.00">
</div>
</div>
<div class="form-group" style="margin-top: 10px;">
<label class="pf-label"><?= tr('hdl_label_notes') ?></label>
<textarea name="notes" id="inp_notes" class="pf-input" rows="2" placeholder="<?= tr('hdl_ph_notes') ?>"></textarea>
</div>
<div class="modal-footer">
<button type="button" onclick="deleteHoliday()" id="btn_delete" class="pf-btn btn-secondary hol-btn-delete"><?= tr('btn_delete') ?></button>
<button type="button" onclick="closeHolidayModal()" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
+1 -1
View File
@@ -19,7 +19,7 @@ $grandTotal = $totalGeneral + $totalSteps;
<h1 style="font-size: 2.5rem; color: #0f172a; margin-bottom: 10px;"><?= htmlspecialchars($holiday['title'] ?? $holiday['name'] ?? 'Mon Voyage') ?></h1>
<h2 style="font-size: 1.5rem; color: #64748b; font-weight: normal; margin-top: 0;">
<?php if (!empty($holiday['start_date']) && !empty($holiday['end_date'])): ?>
Du <?= date('d/m/Y', strtotime($holiday['start_date'])) ?> au <?= date('d/m/Y', strtotime($holiday['end_date'])) ?>
Du <?= date('d/m', strtotime($holiday['start_date'])) ?> au <?= date('d/m', strtotime($holiday['end_date'])) ?>
<?php else: ?>
Dates à définir
<?php endif; ?>
-575
View File
@@ -1,575 +0,0 @@
/* ── Module Liste ─────────────────────────────────────────────────────────── */
.liste-main {
max-width: 680px;
margin: 0 auto;
padding: 1rem 1rem 4rem;
}
/* ── Tabs bar ──────────────────────────────────────────────────────────────── */
.liste-tabs-bar {
display: flex;
align-items: center;
gap: .4rem;
margin-bottom: .75rem;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.liste-tabs-bar::-webkit-scrollbar { display: none; }
.liste-tabs {
display: flex;
gap: .35rem;
flex-wrap: wrap;
}
.liste-tab {
display: flex;
align-items: center;
gap: .25rem;
padding: .4rem .75rem;
border-radius: 20px;
border: 1px solid var(--border, #dee2e6);
background: var(--card-bg, #fff);
cursor: pointer;
font-size: .875rem;
white-space: nowrap;
transition: background .15s, border-color .15s;
user-select: none;
}
.liste-tab:hover { background: var(--hover-bg, #f8f9fa); }
.liste-tab.active {
background: var(--primary, #4361ee);
color: #fff;
border-color: var(--primary, #4361ee);
font-weight: 600;
}
.liste-tab-name { pointer-events: none; }
.liste-tab-btn {
background: none;
border: none;
padding: 0 .1rem;
cursor: pointer;
font-size: .8rem;
color: rgba(255,255,255,.75);
line-height: 1;
transition: color .15s;
}
.liste-tab-btn:hover { color: #fff; }
.liste-tab-add {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px dashed var(--border, #dee2e6);
background: none;
cursor: pointer;
font-size: 1.1rem;
color: var(--muted, #6c757d);
display: flex;
align-items: center;
justify-content: center;
transition: border-color .15s, color .15s;
line-height: 1;
}
.liste-tab-add:hover {
border-color: var(--primary, #4361ee);
color: var(--primary, #4361ee);
}
/* ── New list form ─────────────────────────────────────────────────────────── */
.liste-new-form {
display: flex;
gap: .5rem;
align-items: center;
margin-bottom: .75rem;
flex-wrap: wrap;
}
.liste-new-form input {
flex: 1;
min-width: 160px;
padding: .45rem .75rem;
border: 1px solid var(--primary, #4361ee);
border-radius: 8px;
font-size: .9rem;
background: var(--input-bg, #fff);
color: var(--text, #212529);
}
.liste-new-form button {
padding: .45rem .9rem;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: .875rem;
}
.liste-new-form #btn-confirm-new-list {
background: var(--primary, #4361ee);
color: #fff;
}
.liste-new-form #btn-cancel-new-list {
background: var(--muted-bg, #e9ecef);
color: var(--text, #212529);
}
.hidden { display: none !important; }
/* ── Add card ──────────────────────────────────────────────────────────────── */
.liste-add-card {
display: flex;
gap: .5rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 12px;
padding: .6rem .75rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.05);
}
.liste-add-card input {
flex: 1;
border: none;
outline: none;
font-size: .95rem;
background: transparent;
color: var(--text, #212529);
}
.liste-add-card button {
background: var(--primary, #4361ee);
color: #fff;
border: none;
border-radius: 8px;
padding: .4rem .9rem;
cursor: pointer;
font-size: .875rem;
font-weight: 600;
white-space: nowrap;
}
/* ── Toolbar ───────────────────────────────────────────────────────────────── */
.liste-toolbar {
display: flex;
gap: .4rem;
flex-wrap: wrap;
margin-bottom: .75rem;
}
.btn-tool {
padding: .35rem .75rem;
border-radius: 8px;
border: 1px solid var(--border, #dee2e6);
background: var(--card-bg, #fff);
cursor: pointer;
font-size: .8rem;
color: var(--text, #212529);
transition: background .15s;
}
.btn-tool:hover { background: var(--hover-bg, #f8f9fa); }
.btn-tool-danger { color: var(--danger, #e63946); border-color: var(--danger, #e63946); }
.btn-tool-danger:hover { background: rgba(230,57,70,.08); }
/* ── Items ─────────────────────────────────────────────────────────────────── */
.liste-row {
display: flex;
align-items: center;
gap: .5rem;
padding: .55rem .75rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 10px;
margin-bottom: .4rem;
transition: opacity .2s;
}
.liste-row.in-cart { opacity: .55; }
.liste-row.in-cart .liste-label { text-decoration: line-through; }
.liste-check-label {
display: flex;
align-items: center;
gap: .6rem;
flex: 1;
cursor: pointer;
}
.liste-check {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--primary, #4361ee);
cursor: pointer;
flex-shrink: 0;
}
.liste-label { font-size: .95rem; }
.liste-row-actions {
display: flex;
gap: .25rem;
opacity: 0;
transition: opacity .15s;
}
.liste-row:hover .liste-row-actions,
.liste-row:focus-within .liste-row-actions { opacity: 1; }
.btn-edit-item, .btn-delete-item {
background: none;
border: none;
cursor: pointer;
padding: .2rem .3rem;
border-radius: 6px;
font-size: .9rem;
transition: background .15s;
}
.btn-edit-item:hover { background: var(--hover-bg, #f0f0f0); }
.btn-delete-item:hover { background: rgba(230,57,70,.1); }
.liste-cart-divider {
font-size: .78rem;
color: var(--muted, #6c757d);
text-transform: uppercase;
letter-spacing: .04em;
padding: .5rem .25rem .25rem;
margin-top: .25rem;
}
.liste-empty-items {
text-align: center;
padding: 2rem 1rem;
color: var(--muted, #6c757d);
}
.liste-empty-items span { font-size: 2rem; display: block; margin-bottom: .5rem; }
/* ── History ───────────────────────────────────────────────────────────────── */
.liste-history-card {
margin-top: 1.25rem;
padding: .75rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 12px;
}
.liste-history-title {
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted, #6c757d);
margin-bottom: .5rem;
}
.liste-history-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
.liste-chip {
padding: .25rem .65rem;
border-radius: 20px;
border: 1px solid var(--border, #dee2e6);
background: var(--muted-bg, #f8f9fa);
cursor: pointer;
font-size: .82rem;
color: var(--text, #212529);
transition: background .15s, border-color .15s;
}
.liste-chip:hover {
background: var(--primary-light, #eef0fd);
border-color: var(--primary, #4361ee);
color: var(--primary, #4361ee);
}
/* ── Toast ─────────────────────────────────────────────────────────────────── */
.liste-toast-container {
position: fixed;
bottom: 1.5rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: .4rem;
z-index: 9999;
}
.liste-toast {
padding: .55rem 1rem;
border-radius: 8px;
font-size: .875rem;
background: #333;
color: #fff;
box-shadow: 0 3px 12px rgba(0,0,0,.2);
opacity: 0;
transform: translateY(8px);
transition: opacity .2s, transform .2s;
}
.liste-toast.show { opacity: 1; transform: translateY(0); }
.liste-toast-warn { background: #f0a500; }
.liste-toast-error { background: #e63946; }
/* ── Empty state (no lists at all) ────────────────────────────────────────── */
.liste-empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--muted, #6c757d);
}
.liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; }
/* ── List modal ────────────────────────────────────────────────────────────── */
.liste-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.liste-modal {
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 14px;
width: 100%;
max-width: 420px;
box-shadow: 0 20px 60px rgba(0,0,0,.2);
}
.liste-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border, #dee2e6);
}
.liste-modal-header h3 { margin: 0; font-size: .95rem; font-weight: 700; color: var(--text, #212529); }
.liste-modal-close {
background: none;
border: none;
font-size: 1.3rem;
cursor: pointer;
color: var(--muted, #6c757d);
line-height: 1;
padding: 0 .25rem;
transition: color .15s;
}
.liste-modal-close:hover { color: var(--text, #212529); }
.liste-modal-body {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .85rem;
}
.liste-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: .5rem;
padding: .85rem 1.25rem;
border-top: 1px solid var(--border, #dee2e6);
}
.liste-modal-footer .btn-tool-danger { margin-right: auto; }
.liste-form-group { display: flex; flex-direction: column; gap: .3rem; }
.liste-form-label {
font-size: .73rem;
font-weight: 700;
color: var(--muted, #6c757d);
text-transform: uppercase;
letter-spacing: .04em;
}
.liste-modal-name, .liste-modal-type {
padding: .5rem .7rem;
border: 1px solid var(--border, #dee2e6);
border-radius: 8px;
background: var(--input-bg, #fff);
color: var(--text, #212529);
font-size: .875rem;
font-family: inherit;
width: 100%;
box-sizing: border-box;
transition: border-color .15s;
}
.liste-modal-name:focus, .liste-modal-type:focus {
outline: none;
border-color: var(--primary, #4361ee);
box-shadow: 0 0 0 3px rgba(67,97,238,.15);
}
.btn-tool-primary {
background: var(--primary, #4361ee);
color: #fff;
border-color: var(--primary, #4361ee);
}
.btn-tool-primary:hover { filter: brightness(1.1); background: var(--primary, #4361ee); }
/* ── Color picker ──────────────────────────────────────────────────────────── */
.liste-color-picker {
display: flex;
flex-wrap: wrap;
gap: .45rem;
}
.liste-color-swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 3px solid transparent;
background: var(--swatch, transparent);
cursor: pointer;
transition: transform .1s;
outline: 2px solid transparent;
outline-offset: 2px;
box-sizing: border-box;
}
.liste-color-swatch:hover { transform: scale(1.2); }
.liste-color-swatch.active {
outline-color: var(--swatch, var(--primary, #4361ee));
border-color: var(--card-bg, #fff);
}
.liste-color-swatch-none {
background: transparent !important;
border: 2px dashed var(--border, #dee2e6) !important;
}
.liste-color-swatch-none.active { outline-color: var(--primary, #4361ee); }
/* ── Tab type emoji ────────────────────────────────────────────────────────── */
.liste-tab-type { font-size: .85rem; line-height: 1; }
/* ── Category section headers ──────────────────────────────────────────────── */
.liste-cat-section-header {
font-size: .75rem;
font-weight: 700;
color: var(--muted, #6c757d);
text-transform: uppercase;
letter-spacing: .05em;
padding: .6rem .25rem .2rem;
display: flex;
align-items: center;
gap: .4rem;
}
.liste-cat-section-header:first-child { padding-top: .1rem; }
.liste-cat-section-count {
background: var(--muted-bg, #e9ecef);
color: var(--muted, #6c757d);
border-radius: 999px;
font-size: .7rem;
padding: .05rem .4rem;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
/* ── Category badge (per-item) ─────────────────────────────────────────────── */
.liste-cat-badge {
flex-shrink: 0;
background: none;
border: 1px solid var(--border, #dee2e6);
border-radius: 6px;
padding: .1rem .3rem;
cursor: pointer;
font-size: .85rem;
line-height: 1.4;
color: inherit;
transition: border-color .15s, background .15s;
}
.liste-cat-badge:hover {
border-color: var(--primary, #4361ee);
background: var(--primary-light, #eef0fd);
}
.liste-cat-badge-empty {
opacity: .35;
border-style: dashed;
}
.liste-row:hover .liste-cat-badge-empty { opacity: .7; }
/* ── Category picker popover ───────────────────────────────────────────────── */
.liste-cat-picker {
position: fixed;
z-index: 9999;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,.15);
width: 216px;
overflow: hidden;
}
.liste-cat-picker-title {
font-size: .72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted, #6c757d);
padding: .6rem .75rem .35rem;
border-bottom: 1px solid var(--border, #dee2e6);
}
.liste-cat-picker-list {
overflow-y: auto;
max-height: 300px;
padding: .3rem;
}
.liste-cat-picker-item {
display: flex;
align-items: center;
gap: .4rem;
width: 100%;
text-align: left;
background: none;
border: none;
border-radius: 6px;
padding: .4rem .6rem;
font-size: .83rem;
color: var(--text, #212529);
cursor: pointer;
transition: background .1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.liste-cat-picker-item:hover { background: var(--hover-bg, #f8f9fa); }
.liste-cat-picker-item.active {
background: var(--primary-light, #eef0fd);
color: var(--primary, #4361ee);
font-weight: 600;
}
/* ── Dark mode ─────────────────────────────────────────────────────────────── */
[data-theme='dark'] .liste-tab { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .liste-tab:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-tab.active { background: #4361ee; color: #fff; border-color: #4361ee; }
[data-theme='dark'] .liste-add-card,
[data-theme='dark'] .liste-row,
[data-theme='dark'] .liste-history-card { background: #1e1e2e; border-color: #3a3a4e; }
[data-theme='dark'] .liste-add-card input { color: #cdd6f4; }
[data-theme='dark'] .liste-chip { background: #2a2a3e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .liste-chip:hover { background: #313155; border-color: #4361ee; color: #89b4fa; }
[data-theme='dark'] .btn-tool { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .btn-tool:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; }
[data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; }
[data-theme='dark'] .liste-modal { background: #1e1e2e; border-color: #3a3a4e; }
[data-theme='dark'] .liste-modal-header,
[data-theme='dark'] .liste-modal-footer { border-color: #3a3a4e; }
[data-theme='dark'] .liste-modal-header h3 { color: #cdd6f4; }
[data-theme='dark'] .liste-modal-name,
[data-theme='dark'] .liste-modal-type { background: #2a2a3e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .liste-color-swatch.active { border-color: #1e1e2e; }
[data-theme='dark'] .liste-cat-badge { border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; }
[data-theme='dark'] .liste-cat-section-count { background: #2a2a3e; color: #8892b0; }
[data-theme='dark'] .liste-cat-picker { background: #1e1e2e; border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-picker-title { border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-picker-item { color: #cdd6f4; }
[data-theme='dark'] .liste-cat-picker-item:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-cat-picker-item.active { background: #313155; color: #89b4fa; }
+1 -1
View File
@@ -153,7 +153,7 @@ function renderAttachmentsView(atts,noteId){
<div class="memo-att-title">🖼️ Images</div>
<div class="att-img-grid">${images.map(a=>`
<div class="att-img-item" onclick="openLightbox('${API}?action=file&id=${a.id}')">
<img src="${API}?action=file&id=${a.id}" loading="lazy" alt="${escHtml(a.original_name||'')}">
<img src="${API}?action=file&id=${a.id}" alt="${escHtml(a.original_name||'')}">
<button class="att-del" onclick="event.stopPropagation();deleteAttachment(${a.id},${noteId})" title="Supprimer">✕</button>
</div>`).join('')}</div></div>`;
}
+6 -16
View File
@@ -26,33 +26,23 @@ $stops = $params['stops'];
$fuelL100 = (float)($params['fuel_l100'] ?? 7);
$fuelPrice= (float)($params['fuel_price'] ?? 1.85);
// --- Load databases ---
$tollsPath = __DIR__ . '/data/tolls.json';
$gpsPath = __DIR__ . '/data/toll_gps.json';
// --- Load pre-indexed PHP database ---
$tollsDataPath = __DIR__ . '/data/tolls.data.php';
$gpsPath = __DIR__ . '/data/toll_gps.json';
if (!file_exists($tollsPath)) {
if (!file_exists($tollsDataPath)) {
echo json_encode(['ok' => false, 'error' => 'Base de péages introuvable']);
exit;
}
$tollsRaw = json_decode(file_get_contents($tollsPath), true);
$tollData = $tollsRaw['data'] ?? [];
// Grâce à OPcache, le fichier est exécuté et stocké en RAM partagée
$tollIndex = require $tollsDataPath;
$gpsData = [];
if (file_exists($gpsPath)) {
$gpsData = json_decode(file_get_contents($gpsPath), true) ?? [];
}
// --- Build lookup index: [op][entry][exit] => c1_price ---
$tollIndex = [];
foreach ($tollData as $row) {
$op = $row['op'];
$e = $row['e'];
$x = $row['x'];
$c1 = (float)$row['c1'];
$tollIndex[$op][$e][$x] = $c1;
}
// --- Helper: great-circle distance (km) ---
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
$R = 6371.0;
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
{"generated":"2026-02-01","stats":{"APRR":20964,"AREA":674,"COFIROUTE":1296,"SANEF":1620,"SAPN":126},"data":[{"op":"APRR","e":"ALLAINES","x":"AMBERIEU","c1":58.2,"c2":89.1,"c3":143.0,"c4":190.7,"c5":32.8},{"op":"APRR","e":"ALLAINES","x":"ARLAY","c1":52.3,"c2":80.9,"c3":131.7,"c4":173.9,"c5":29.1},{"op":"APRR","e":"ALLAINES","x":"AUXERRE NORD","c1":30.1,"c2":45.5,"c3":75.3,"c4":96.7,"c5":15.6},{"op":"APRR","e":"ALLAINES","x":"AUXERRE SUD","c1":30.1,"c2":45.5,"c3":75.3,"c4":96.7,"c5":15.6},{"op":"APRR","e":"ALLAINES","x":"AVALLON","c1":34.3,"c2":52.2,"c3":86.3,"c4":112.4,"c5":18.5},{"op":"APRR","e":"ALLAINES","x":"BALAN","c1":59.1,"c2":91.2,"c3":145.8,"c4":194.0,"c5":33.4},{"op":"APRR","e":"ALLAINES","x":"BAUME-LES-DAMES","c1":55.4,"c2":85.6,"c3":138.1,"c4":184.1,"c5":31.3},{"op":"APRR","e":"ALLAINES","x":"BEAUNE NORD","c1":42.8,"c2":66.0,"c3":107.1,"c4":140.1,"c5":23.6},{"op":"APRR","e":"ALLAINES","x":"BEAUNE SUD","c1":44.1,"c2":67.5,"c3":109.7,"c4":144.2,"c5":24.2},{"op":"APRR","e":"ALLAINES","x":"BEAUPONT","c1":55.6,"c2":86.1,"c3":139.6,"c4":185.5,"c5":31.6},{"op":"APRR","e":"ALLAINES","x":"BEAUREPAIRE","c1":53.5,"c2":82.6,"c3":133.8,"c4":178.4,"c5":29.8},{"op":"APRR","e":"ALLAINES","x":"BELLEVILLE S/SAONE","c1":53.6,"c2":82.5,"c3":132.4,"c4":176.0,"c5":29.8},{"op":"APRR","e":"ALLAINES","x":"BERSAILLIN","c1":52.1,"c2":79.4,"c3":130.2,"c4":172.2,"c5":29.0},{"op":"APRR","e":"ALLAINES","x":"BESANCON EST","c1":52.6,"c2":82.0,"c3":132.6,"c4":176.7,"c5":29.6},{"op":"APRR","e":"ALLAINES","x":"BESANCON NORD","c1":52.6,"c2":80.9,"c3":131.0,"c4":173.7,"c5":29.2},{"op":"APRR","e":"ALLAINES","x":"BESANCON OUEST","c1":52.6,"c2":80.3,"c3":130.5,"c4":172.6,"c5":29.1},{"op":"APRR","e":"ALLAINES","x":"BEYNOST","c1":61.6,"c2":95.3,"c3":152.0,"c4":203.0,"c5":35.4},{"op":"APRR","e":"ALLAINES","x":"BIERRE-LES-SEMUR","c1":36.4,"c2":55.1,"c3":90.9,"c4":118.7,"c5":19.7},{"op":"APRR","e":"ALLAINES","x":"BOURG NORD","c1":53.8,"c2":83.0,"c3":132.6,"c4":176.7,"c5":30.2},{"op":"APRR","e":"ALLAINES","x":"BOURG SUD","c1":55.1,"c2":85.3,"c3":136.7,"c4":181.9,"c5":31.6},{"op":"APRR","e":"ALLAINES","x":"BULGNEVILLE","c1":47.1,"c2":72.3,"c3":116.4,"c4":155.0,"c5":25.8},{"op":"APRR","e":"ALLAINES","x":"CHALON CENTRE","c1":46.5,"c2":71.7,"c3":114.7,"c4":151.6,"c5":25.5},{"op":"APRR","e":"ALLAINES","x":"CHALON NORD","c1":46.5,"c2":71.7,"c3":114.7,"c4":151.6,"c5":25.5},{"op":"APRR","e":"ALLAINES","x":"CHALON SUD","c1":46.5,"c2":71.7,"c3":114.7,"c4":151.6,"c5":25.5},{"op":"APRR","e":"ALLAINES","x":"CHATENOIS","c1":47.7,"c2":73.0,"c3":118.2,"c4":157.0,"c5":26.4},{"op":"APRR","e":"ALLAINES","x":"CHATILLON-LABORDE","c1":33.4,"c2":50.8,"c3":83.2,"c4":108.1,"c5":18.0},{"op":"APRR","e":"ALLAINES","x":"CHAUMONT-SEMOUTIERS","c1":40.1,"c2":61.1,"c3":100.1,"c4":131.6,"c5":21.7},{"op":"APRR","e":"ALLAINES","x":"CHOISEY","c1":48.9,"c2":74.6,"c3":121.6,"c4":160.9,"c5":26.9},{"op":"APRR","e":"ALLAINES","x":"COLOMBEY-LES-BELLES","c1":50.5,"c2":76.9,"c3":124.6,"c4":166.7,"c5":27.7},{"op":"APRR","e":"ALLAINES","x":"COMBRONDE","c1":32.5,"c2":50.8,"c3":79.2,"c4":106.8,"c5":19.4},{"op":"APRR","e":"ALLAINES","x":"COURTENAY","c1":25.6,"c2":38.6,"c3":64.4,"c4":81.7,"c5":13.1},{"op":"APRR","e":"ALLAINES","x":"DIJON SUD","c1":46.7,"c2":72.1,"c3":116.0,"c4":153.2,"c5":25.6},{"op":"APRR","e":"ALLAINES","x":"DIJON-ARC S/TILLE","c1":48.4,"c2":74.5,"c3":119.9,"c4":158.6,"c5":26.7},{"op":"APRR","e":"ALLAINES","x":"DIJON-CRIMOLOIS","c1":47.7,"c2":72.7,"c3":118.7,"c4":156.0,"c5":26.4},{"op":"APRR","e":"ALLAINES","x":"DOLE","c1":48.9,"c2":74.6,"c3":121.6,"c4":160.9,"c5":26.9},{"op":"APRR","e":"ALLAINES","x":"FEILLENS","c1":51.2,"c2":79.0,"c3":127.7,"c4":169.8,"c5":28.9},{"op":"APRR","e":"ALLAINES","x":"FLEURY-EN-BIERE","c1":32.0,"c2":48.7,"c3":79.6,"c4":102.6,"c5":16.9},{"op":"APRR","e":"ALLAINES","x":"FONTAINEBLEAU","c1":29.3,"c2":44.3,"c3":73.4,"c4":93.2,"c5":15.3},{"op":"APRR","e":"ALLAINES","x":"FORET DE TRONCAIS","c1":24.8,"c2":39.6,"c3":60.2,"c4":79.6,"c5":15.1},{"op":"APRR","e":"ALLAINES","x":"FORGES","c1":31.0,"c2":46.5,"c3":76.6,"c4":99.2,"c5":15.9},{"op":"APRR","e":"ALLAINES","x":"GANNAT","c1":32.2,"c2":50.7,"c3":78.4,"c4":106.1,"c5":19.3},{"op":"APRR","e":"ALLAINES","x":"GENDREY","c1":50.0,"c2":76.9,"c3":125.1,"c4":166.3,"c5":28.0},{"op":"APRR","e":"ALLAINES","x":"GERZAT-VILLE","c1":34.1,"c2":53.7,"c3":82.8,"c4":110.4,"c5":20.5},{"op":"APRR","e":"ALLAINES","x":"GROISSIAT","c1":62.0,"c2":95.9,"c3":154.1,"c4":205.8,"c5":35.6},{"op":"APRR","e":"ALLAINES","x":"GYE","c1":52.8,"c2":80.2,"c3":128.9,"c4":172.1,"c5":29.0},{"op":"APRR","e":"ALLAINES","x":"JOIGNY","c1":27.3,"c2":41.0,"c3":67.9,"c4":86.8,"c5":14.1},{"op":"APRR","e":"ALLAINES","x":"L ISLE-S/LE-DOUBS","c1":57.0,"c2":87.9,"c3":141.6,"c4":188.7,"c5":32.1},{"op":"APRR","e":"ALLAINES","x":"LA BOISSE","c1":63.1,"c2":97.6,"c3":156.2,"c4":208.8,"c5":36.2},{"op":"APRR","e":"ALLAINES","x":"LA BOISSE - MONTLUEL","c1":60.7,"c2":93.3,"c3":147.1,"c4":195.9,"c5":34.6},{"op":"APRR","e":"ALLAINES","x":"LA COTIERE","c1":61.5,"c2":Line truncated
+260 -109
View File
@@ -33,23 +33,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Session invalide (CSRF). Rechargez la page.']);
echo json_encode(['success' => false, 'error' => tr('error_csrf')]);
exit;
}
$error = "Session invalide (CSRF). Rechargez la page.";
$error = tr('error_csrf');
} else {
$action = $_POST['action'] ?? '';
if ($action === 'set_modules' && $family_id) {
$all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo', 'liste', 'calendar_ios', 'printvault', 'planka'];
$all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo', 'food', 'calendar_ios', 'printvault', 'planka'];
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
if (empty($enabled)) {
$error = "Vous devez garder au moins un module actif.";
$error = tr('set_err_min_module');
} else {
$meta_pdo->prepare("UPDATE families SET enabled_modules = ? WHERE id = ?")
->execute([json_encode($enabled), $family_id]);
$_SESSION['enabled_modules'] = $enabled;
$success = "Modules mis à jour avec succès.";
$success = tr('set_success_modules');
}
}
@@ -59,7 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_SESSION['app_lang'] = $lang;
$meta_pdo->prepare("UPDATE users SET lang = ? WHERE id = ?")
->execute([$lang, $user_id]);
$success = "Langue mise à jour.";
$success = tr('set_success_lang');
}
}
@@ -68,7 +68,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$color = trim($_POST['color'] ?? '#0891b2');
if (!$display_name) {
$error = "Le prénom ne peut pas être vide.";
$error = tr('set_err_empty_name');
} else {
$meta_pdo->prepare("UPDATE users SET display_name = ? WHERE id = ?")
->execute([$display_name, $user_id]);
@@ -80,7 +80,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
$_SESSION['user']['display_name'] = $display_name;
$success = "Profil mis à jour avec succès.";
$success = tr('set_success_profile');
}
}
@@ -94,15 +94,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$row = $row->fetch();
if (!password_verify($current, $row['password_hash'])) {
$error = "Mot de passe actuel incorrect.";
$error = tr('set_err_wrong_pwd');
} elseif (strlen($new) < 6) {
$error = "Le nouveau mot de passe doit faire au moins 6 caractères.";
$error = tr('set_err_pwd_length');
} elseif ($new !== $confirm) {
$error = "Les mots de passe ne correspondent pas.";
$error = tr('set_err_pwd_match');
} else {
$meta_pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
->execute([password_hash($new, PASSWORD_BCRYPT), $user_id]);
$success = "Mot de passe modifié avec succès.";
$success = tr('set_success_pwd');
}
}
@@ -111,7 +111,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($name) {
$meta_pdo->prepare("UPDATE families SET name = ? WHERE id = ?")
->execute([$name, $family_id]);
$success = "Nom de l'espace mis à jour.";
$success = tr('set_success_family');
}
}
@@ -119,7 +119,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$new_code = bin2hex(random_bytes(8));
$meta_pdo->prepare("UPDATE families SET invite_code = ? WHERE id = ?")
->execute([$new_code, $family_id]);
$success = "Nouveau code d'invitation généré.";
$success = tr('set_success_invite');
}
if ($action === 'upload_home_bg' && $family_id) {
@@ -129,14 +129,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($file && $file['error'] === 0) {
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg','jpeg','png','webp'])) {
$error = "Format non supporté (jpg, png, webp).";
$error = tr('set_err_bg_format');
} else {
$dest = $upload_dir . 'home_bg_' . $family_id . '.' . $ext;
foreach (glob($upload_dir . 'home_bg_' . $family_id . '.*') as $old) @unlink($old);
if (move_uploaded_file($file['tmp_name'], $dest)) {
$success = "Image d'accueil mise à jour.";
$success = tr('set_success_bg');
} else {
$error = "Erreur lors de l'upload.";
$error = tr('set_err_upload');
}
}
}
@@ -144,13 +144,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'reset_home_bg' && $family_id) {
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $old) @unlink($old);
$success = "Image d'accueil réinitialisée.";
$success = tr('set_success_bg_reset');
}
if ($action === 'grocery_history_max' && $family_id) {
require_once __DIR__ . '/includes/db.php';
if (!isset($pdo)) {
$error = "Base famille indisponible.";
$error = tr('set_err_db_unavailable');
} else {
$n = (int) ($_POST['history_max'] ?? 20);
$n = max(1, min(50, $n));
@@ -171,7 +171,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$calendarUrl = trim($_POST['icloud_calendar_url'] ?? '');
if (!$username || !$appPassword || !$calendarUrl) {
$error = "Merci de renseigner identifiant iCloud, mot de passe d'app et URL CalDAV.";
$error = tr('set_err_ios_missing');
} else {
try {
$resolvedUrl = hh_icloud_resolve_calendar_url_if_needed($username, $appPassword, $calendarUrl);
@@ -181,13 +181,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
VALUES (?, 'icloud_caldav', ?, ?, ?, 'connected', NOW())
ON DUPLICATE KEY UPDATE username=VALUES(username), secret_encrypted=VALUES(secret_encrypted), calendar_url=VALUES(calendar_url), status='connected', updated_at=NOW()
")->execute([$user_id, $username, $encrypted, $resolvedUrl]);
$msg = "Connexion calendrier iOS enregistrée.";
$msg = tr('set_success_ios');
if (rtrim($resolvedUrl, '/') !== rtrim($calendarUrl, '/')) {
$msg .= " URL du calendrier détectée automatiquement.";
$msg .= tr('set_success_ios_auto');
}
$success = $msg;
} catch (\Throwable $e) {
$error = "Impossible d'enregistrer la connexion iOS: " . $e->getMessage();
$error = tr('set_err_ios_save') . $e->getMessage();
}
}
}
@@ -197,7 +197,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$row->execute([$user_id]);
$integration = $row->fetch();
if (!$integration) {
$error = "Aucune connexion iOS configurée.";
$error = tr('set_err_ios_none');
} else {
try {
$pwd = hh_normalize_apple_app_password(hh_decrypt_secret($integration['secret_encrypted']));
@@ -209,31 +209,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$meta_pdo->prepare("UPDATE user_calendar_integrations SET calendar_url = ? WHERE id = ?")
->execute([$resolvedUrl, $integration['id']]);
}
$success = "Connexion iCloud CalDAV valide (HTTP $code).";
$success = sprintf(tr('set_success_ios_test'), $code);
} else {
$error = "Test connexion échoué (HTTP $code). Vérifiez vos identifiants.";
$error = sprintf(tr('set_err_ios_test'), $code);
}
} catch (\Throwable $e) {
$error = "Test connexion impossible: " . $e->getMessage();
$error = tr('set_err_ios_test_fail') . $e->getMessage();
}
}
}
if ($action === 'calendar_ios_disconnect') {
$meta_pdo->prepare("DELETE FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'")->execute([$user_id]);
$success = "Connexion iOS supprimée.";
$success = tr('set_success_ios_del');
}
// --- GESTION DES PROFILS RATTACHÉS (Enfants, Nounous, etc.) ---
if ($action === 'add_attached_profile') {
$name = trim($_POST['profile_name'] ?? '');
$role = trim($_POST['profile_role'] ?? 'child');
$color = trim($_POST['profile_color'] ?? '#3b82f6');
if ($name) {
require_once __DIR__ . '/includes/db.php';
if (isset($pdo)) {
$stmt = $pdo->prepare("INSERT INTO pf_people (name, role) VALUES (?, ?)");
$stmt->execute([$name, $role]);
$stmt = $pdo->prepare("INSERT INTO pf_people (name, role, color) VALUES (?, ?, ?)");
$stmt->execute([$name, $role, $color]);
$success = "Profil de {$name} ajouté avec succès.";
}
} else {
@@ -241,15 +242,60 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
if ($action === 'update_attached_profile_color') {
$profile_id = (int)($_POST['profile_id'] ?? 0);
$color = trim($_POST['color'] ?? '#3b82f6');
if ($profile_id > 0) {
require_once __DIR__ . '/includes/db.php';
if (isset($pdo)) {
$stmt = $pdo->prepare("UPDATE pf_people SET color = ? WHERE id = ? AND user_id IS NULL");
$stmt->execute([$color, $profile_id]);
$success = "Couleur mise à jour.";
}
}
}
if ($action === 'delete_attached_profile') {
$profile_id = (int)($_POST['profile_id'] ?? 0);
if ($profile_id > 0) {
require_once __DIR__ . '/includes/db.php';
if (isset($pdo)) {
// On protège les vrais utilisateurs en s'assurant que user_id est NULL
$stmt = $pdo->prepare("DELETE FROM pf_people WHERE id = ? AND user_id IS NULL");
$stmt->execute([$profile_id]);
$success = "Profil supprimé.";
$success = tr('set_success_profile_del');
}
}
}
if ($action === 'update_profile_color') {
$profile_id = (int)($_POST['profile_id'] ?? 0);
$color = trim($_POST['color'] ?? '#0891b2');
if ($profile_id > 0) {
require_once __DIR__ . '/includes/db.php';
if (isset($pdo)) {
$stmt = $pdo->prepare("UPDATE pf_people SET color = ? WHERE id = ? AND user_id IS NULL");
$stmt->execute([$color, $profile_id]);
$success = tr('set_success_color_profile');
}
}
}
if ($action === 'update_attached_profile_color') {
$profile_id = (int)($_POST['profile_id'] ?? 0);
$color = trim($_POST['color'] ?? '');
// Vérification stricte format Hexadécimal
if ($profile_id > 0 && preg_match('/^#[a-f0-9]{6}$/i', $color)) {
require_once __DIR__ . '/includes/db.php';
if (isset($pdo)) {
// user_id IS NULL garantit qu'on ne modifie pas un vrai compte Parent par accident
$stmt = $pdo->prepare("UPDATE pf_people SET color = ? WHERE id = ? AND user_id IS NULL");
$stmt->execute([$color, $profile_id]);
if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
}
$success = tr('set_success_color_update');
}
}
}
@@ -289,9 +335,8 @@ if ($family_id) {
$mem->execute([$family_id]);
$members = $mem->fetchAll();
// Récupération des profils rattachés (ceux qui n'ont pas de compte de connexion, donc user_id IS NULL)
if (isset($pdo)) {
$stmtAttached = $pdo->query("SELECT id, name, role FROM pf_people WHERE user_id IS NULL ORDER BY role ASC, name ASC");
$stmtAttached = $pdo->query("SELECT id, name, role, color FROM pf_people WHERE user_id IS NULL ORDER BY role ASC, name ASC");
$attached_profiles = $stmtAttached->fetchAll(PDO::FETCH_ASSOC);
}
}
@@ -310,17 +355,17 @@ if ($family_id && isset($pdo)) {
} catch (\Throwable $e) {}
}
$pageTitle = "Paramètres — HouseHub";
$pageTitle = tr('set_title_page');
$activePage = "settings";
require __DIR__ . '/header.php';
?>
<div class="pf-container pf-settings-page">
<h1 class="pf-settings-title">⚙️ Paramètres</h1>
<h1 class="pf-settings-title"><?= tr('set_title_page') ?></h1>
<section class="pf-panel-card">
<h2 class="pf-card-h2">🌐 Langue / Language</h2>
<h2 class="pf-card-h2"><?= tr('set_title_lang') ?></h2>
<form method="post" class="pf-lang-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="set_lang">
@@ -347,8 +392,8 @@ require __DIR__ . '/header.php';
<?php if ($family_id): ?>
<section class="pf-panel-card">
<h2 class="pf-card-h2 pf-card-h2--tight">🧩 Modules actifs</h2>
<p class="pf-muted-note">Choisissez les modules visibles dans la navigation (partagé avec tous les membres de l'espace).</p>
<h2 class="pf-card-h2 pf-card-h2--tight"><?= tr('set_title_modules') ?></h2>
<p class="pf-muted-note"><?= tr('set_desc_modules') ?></p>
<?php
$enabledMods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','calendar_ios'];
$allModules = [
@@ -359,7 +404,7 @@ require __DIR__ . '/header.php';
'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')],
'memo' => ['icon' => '📝', 'label' => tr('menu_memo')],
'todo' => ['icon' => '✅', 'label' => tr('menu_todo')],
'liste' => ['icon' => '📝', 'label' => tr('menu_liste')],
'food' => ['icon' => '🍳', 'label' => tr('menu_food')],
'calendar_ios' => ['icon' => '📱', 'label' => tr('menu_calendar_ios')],
'printvault' => ['icon' => '🖨️', 'label' => tr('menu_printvault')],
'planka' => ['icon' => '📋', 'label' => tr('menu_planka')],
@@ -383,19 +428,19 @@ require __DIR__ . '/header.php';
<?php endif; ?>
<?php
$enabledModsForListe = $_SESSION['enabled_modules'] ?? [];
if ($family_id && in_array('liste', $enabledModsForListe, true)):
$enabledModsForFood = $_SESSION['enabled_modules'] ?? [];
if ($family_id && in_array('food', $enabledModsForFood, true)):
?>
<section class="pf-panel-card">
<h2 class="pf-card-h2 pf-card-h2--tight">📝 <?= htmlspecialchars(tr('liste_settings_title')) ?></h2>
<p class="pf-muted-note"><?= htmlspecialchars(tr('liste_settings_intro')) ?></p>
<form method="post" class="pf-stack-md" style="margin-top:1rem">
<form method="post" class="pf-stack-md pf-mt-md">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="grocery_history_max">
<div class="pf-form-group">
<label class="pf-label" for="history_max"><?= htmlspecialchars(tr('liste_settings_history_max')) ?></label>
<input type="number" name="history_max" id="history_max" class="pf-input" style="max-width:120px" min="1" max="50" step="1" value="<?= (int) $groceryHistoryMaxSetting ?>" required>
<p class="pf-muted-note" style="margin-top:0.35rem"><?= htmlspecialchars(tr('liste_settings_history_hint')) ?></p>
<input type="number" name="history_max" id="history_max" class="pf-input pf-input-sm" min="1" max="50" step="1" value="<?= (int) $groceryHistoryMaxSetting ?>" required>
<p class="pf-muted-note pf-mt-xs"><?= htmlspecialchars(tr('liste_settings_history_hint')) ?></p>
</div>
<button type="submit" class="pf-btn"><?= htmlspecialchars(tr('btn_save')) ?></button>
</form>
@@ -403,21 +448,21 @@ require __DIR__ . '/header.php';
<?php endif; ?>
<section class="pf-panel-card">
<h2 class="pf-card-h2 pf-card-h2--tight">📱 Intégration Calendrier iOS (CalDAV)</h2>
<p class="pf-muted-note">Configurez ici votre calendrier iCloud pour synchroniser les événements créés dans HouseHub.</p>
<h2 class="pf-card-h2 pf-card-h2--tight"><?= tr('set_title_ios') ?></h2>
<p class="pf-muted-note"><?= tr('set_desc_ios') ?></p>
<form method="post" class="pf-stack-md">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="calendar_ios_save">
<div class="pf-form-group">
<label class="pf-label">Identifiant Apple (email iCloud)</label>
<input type="text" name="icloud_username" class="pf-input" value="<?= htmlspecialchars($calendarIntegration['username'] ?? '') ?>" placeholder="nom@icloud.com" required>
<label class="pf-label"><?= tr('set_label_apple_id') ?></label>
<input type="text" name="icloud_username" class="pf-input" value="<?= htmlspecialchars($calendarIntegration['username'] ?? '') ?>" placeholder="<?= tr('placeholder_apple_id') ?>" required>
</div>
<div class="pf-form-group">
<label class="pf-label">Mot de passe d'app Apple</label>
<input type="password" name="icloud_app_password" class="pf-input" placeholder="xxxx-xxxx-xxxx-xxxx" required>
<label class="pf-label"><?= tr('set_label_apple_pwd') ?></label>
<input type="password" name="icloud_app_password" class="pf-input" placeholder="<?= tr('placeholder_apple_pwd') ?>" required>
</div>
<div class="pf-form-group">
<label class="pf-label">URL du calendrier CalDAV</label>
<label class="pf-label"><?= tr('set_label_apple_url') ?></label>
<input type="url" name="icloud_calendar_url" class="pf-input" value="<?= htmlspecialchars($calendarIntegration['calendar_url'] ?? '') ?>" placeholder="https://caldav.icloud.com/..." required>
</div>
<div class="pf-flex-gap-8">
@@ -429,12 +474,12 @@ require __DIR__ . '/header.php';
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="calendar_ios_test">
<button type="submit" class="pf-btn btn-secondary">Tester la connexion</button>
<button type="submit" class="pf-btn btn-secondary"><?= tr('set_btn_test_ios') ?></button>
</form>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="calendar_ios_disconnect">
<button type="submit" class="pf-btn btn-secondary">Déconnecter</button>
<button type="submit" class="pf-btn btn-secondary"><?= tr('set_btn_disconnect_ios') ?></button>
</form>
</div>
</section>
@@ -445,13 +490,13 @@ require __DIR__ . '/header.php';
$has_custom_bg = $bg_file !== null;
?>
<section class="pf-panel-card">
<h2 class="pf-card-h2 pf-card-h2--tight">🖼️ Image d'accueil</h2>
<p class="pf-muted-note">Photo de fond affichée sur la page d'accueil (partagée avec tous les membres).</p>
<h2 class="pf-card-h2 pf-card-h2--tight"><?= tr('set_title_bg') ?></h2>
<p class="pf-muted-note"><?= tr('set_desc_bg') ?></p>
<?php if ($has_custom_bg): ?>
<div class="pf-home-bg-preview">
<img src="/home-bg.php" alt="Fond actuel">
<span class="pf-badge-overlay">Image personnalisée</span>
<span class="pf-badge-overlay"><?= tr('set_badge_custom_bg') ?></span>
</div>
<?php endif; ?>
@@ -459,10 +504,10 @@ require __DIR__ . '/header.php';
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="upload_home_bg">
<div class="pf-form-group">
<label class="pf-label">Nouvelle image (jpg, png, webp — max 10 Mo)</label>
<label class="pf-label"><?= tr('set_label_new_bg') ?></label>
<input type="file" name="home_bg" class="pf-input" accept="image/jpeg,image/png,image/webp" required>
</div>
<button type="submit" class="pf-btn pf-shrink-0">Enregistrer</button>
<button type="submit" class="pf-btn pf-shrink-0"><?= tr('btn_save') ?></button>
</form>
<?php if ($has_custom_bg): ?>
@@ -470,32 +515,49 @@ require __DIR__ . '/header.php';
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="reset_home_bg">
<button type="submit" class="pf-btn btn-secondary pf-btn-sm-text"
onclick="return confirm('Revenir à l\'image par défaut ?')">🔄 Remettre l'image par défaut</button>
onclick="return confirm('<?= addslashes(tr('set_confirm_reset_bg')) ?>')"><?= tr('set_btn_reset_bg') ?></button>
</form>
<?php endif; ?>
</section>
<?php endif; ?>
<section class="pf-panel-card">
<h2 class="pf-card-h2">👤 Mon profil</h2>
<h2 class="pf-card-h2"><?= tr('set_title_profile') ?></h2>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="update_profile">
<div class="pf-form-group">
<label class="pf-label">Nom d'utilisateur</label>
<label class="pf-label"><?= tr('set_label_username') ?></label>
<input type="text" class="pf-input pf-input--disabled-muted" value="<?= htmlspecialchars($user['username']) ?>" disabled>
</div>
<div class="pf-form-group">
<label class="pf-label">Prénom / Pseudo affiché</label>
<label class="pf-label"><?= tr('set_label_display_name') ?></label>
<input type="text" name="display_name" class="pf-input" value="<?= htmlspecialchars($user['display_name']) ?>" required>
</div>
<div class="pf-form-group">
<label class="pf-label" for="profile_color">Couleur thématique de mon profil</label>
<div class="pf-color-picker-group">
<input type="color" name="color" id="profile_color" class="pf-input-color" value="<?= htmlspecialchars($user_color) ?>">
<span class="pf-muted-note">Cette couleur sera utilisée pour vos grilles de budget, calendriers et indicateurs personnels.</span>
<!-- Le champ caché qui va stocker la valeur -->
<input type="hidden" name="color" id="profile_color" value="<?= htmlspecialchars($user_color) ?>">
<!-- Le panel de couleurs prédéfinies -->
<div class="pf-color-palette" id="color-palette">
<?php
$palette = ['#ef4444', '#f97316', '#f59e0b', '#84cc16', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#d946ef', '#f43f5e', '#64748b'];
foreach ($palette as $c):
$isSelected = (strtolower($user_color) === $c) ? 'selected' : '';
?>
<button type="button"
class="pf-color-swatch <?= $isSelected ?>"
style="background-color: <?= $c ?>;"
data-color="<?= $c ?>"
onclick="selectProfileColor(this, '<?= $c ?>')">
</button>
<?php endforeach; ?>
</div>
<span class="pf-muted-note pf-profile-color-note"><?= tr('set_desc_profile_color') ?></span>
</div>
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
@@ -503,65 +565,65 @@ require __DIR__ . '/header.php';
</section>
<section class="pf-panel-card">
<h2 class="pf-card-h2">🔒 Changer le mot de passe</h2>
<h2 class="pf-card-h2"><?= tr('set_title_pwd') ?></h2>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="change_password">
<div class="pf-form-group">
<label class="pf-label">Mot de passe actuel</label>
<label class="pf-label"><?= tr('set_label_cur_pwd') ?></label>
<input type="password" name="current_password" class="pf-input" required placeholder="••••••">
</div>
<div class="pf-form-group">
<label class="pf-label">Nouveau mot de passe</label>
<label class="pf-label"><?= tr('set_label_new_pwd') ?></label>
<input type="password" name="new_password" class="pf-input" required placeholder="••••••" autocomplete="new-password">
</div>
<div class="pf-form-group">
<label class="pf-label">Confirmer</label>
<label class="pf-label"><?= tr('set_label_confirm_pwd') ?></label>
<input type="password" name="confirm_password" class="pf-input" required placeholder="••••••" autocomplete="new-password">
</div>
<button type="submit" class="pf-btn">Modifier</button>
<button type="submit" class="pf-btn"><?= tr('set_btn_edit') ?></button>
</form>
</section>
<?php if ($family): ?>
<section class="pf-panel-card">
<h2 class="pf-card-h2">🏠 Mon espace familial</h2>
<h2 class="pf-card-h2"><?= tr('set_title_family') ?></h2>
<form method="post" class="pf-mb-section">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="update_family_name">
<div class="pf-form-group">
<label class="pf-label">Nom de l'espace</label>
<label class="pf-label"><?= tr('set_label_family_name') ?></label>
<div class="pf-flex-gap-8">
<input type="text" name="family_name" class="pf-input" value="<?= htmlspecialchars($family['name']) ?>" required>
<button type="submit" class="pf-btn pf-shrink-0">Enregistrer</button>
<button type="submit" class="pf-btn pf-shrink-0"><?= tr('btn_save') ?></button>
</div>
</div>
</form>
<div class="pf-dashed-panel">
<p class="pf-muted-note pf-muted-note--tight">Code d'invitation — partagez-le pour inviter quelqu'un</p>
<p class="pf-muted-note pf-muted-note--tight"><?= tr('set_desc_invite') ?></p>
<div class="pf-invite-row">
<code id="invite-code" class="pf-invite-code"
onclick="copyCode()" title="Cliquer pour copier">
onclick="copyCode()" title="<?= tr('set_title_copy') ?>">
<?= htmlspecialchars($family['invite_code']) ?>
</code>
<span id="copy-msg" class="pf-copy-msg">✓ Copié !</span>
<span id="copy-msg" class="pf-copy-msg"><?= tr('set_msg_copied') ?></span>
<form method="post" class="pf-ml-auto">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="regen_invite">
<button type="submit" class="pf-btn btn-secondary pf-btn-sm-text"
onclick="return confirm('Regénérer le code ? L\'ancien sera invalidé.')">
🔄 Nouveau code
onclick="return confirm('<?= addslashes(tr('set_confirm_new_code')) ?>')">
<?= tr('set_btn_new_code') ?>
</button>
</form>
</div>
<p class="pf-invite-footnote">
Lien d'inscription : <strong><?= $_SERVER['HTTP_HOST'] ?>/register.php</strong>
<?= tr('set_desc_register_link') ?> <strong><?= $_SERVER['HTTP_HOST'] ?>/register.php</strong>
</p>
</div>
<h3 class="pf-members-heading">Membres (<?= count($members) ?>)</h3>
<h3 class="pf-members-heading"><?= tr('set_title_members') ?> (<?= count($members) ?>)</h3>
<div class="pf-stack-sm">
<?php foreach ($members as $m): ?>
<div class="pf-member-card">
@@ -569,7 +631,7 @@ require __DIR__ . '/header.php';
<strong><?= htmlspecialchars($m['display_name']) ?></strong>
<span class="pf-muted-inline">@<?= htmlspecialchars($m['username']) ?></span>
<?php if ($m['is_admin']): ?>
<span class="pf-admin-badge">Admin</span>
<span class="pf-admin-badge"><?= tr('set_badge_admin') ?></span>
<?php endif; ?>
</div>
<span class="pf-muted-tiny"><?= date('d/m/Y', strtotime($m['created_at'])) ?></span>
@@ -577,54 +639,79 @@ require __DIR__ . '/header.php';
<?php endforeach; ?>
</div>
<h3 class="pf-members-heading" style="margin-top: 2rem;">🧸 Profils rattachés</h3>
<p class="pf-muted-note pf-muted-note--tight" style="margin-bottom: 1rem;">Membres gérés par le foyer (Enfants, Nounous, Proches) n'ayant pas d'accès direct.</p>
<h3 class="pf-members-heading pf-mt-lg">🧸 <?= tr('settings_attached_profiles_title') ?></h3>
<p class="pf-muted-note pf-muted-note--tight pf-mb-md"><?= tr('settings_attached_profiles_desc') ?></p>
<div class="pf-stack-sm" style="margin-bottom: 1.5rem;">
<div class="pf-stack-sm" style="margin-bottom: 1.5rem;">
<?php foreach ($attached_profiles as $p):
$roleBadge = 'Inconnu';
if (in_array($p['role'], ['child', 'enfant'])) $roleBadge = '👶 Enfant';
elseif ($p['role'] === 'helper') $roleBadge = '💼 Intervenant';
elseif ($p['role'] === 'relative') $roleBadge = '👵 Proche';
$roleBadge = tr('role_unknown');
if (in_array($p['role'], ['child', 'enfant'])) $roleBadge = tr('role_child_icon');
elseif ($p['role'] === 'helper') $roleBadge = tr('role_helper_icon');
elseif ($p['role'] === 'relative') $roleBadge = tr('role_relative_icon');
$pColor = htmlspecialchars($p['color'] ?? '#3b82f6');
?>
<div class="pf-member-card" style="display: flex; justify-content: space-between; align-items: center;">
<div class="pf-member-card" style="display: flex; justify-content: space-between; align-items: center; border-left: 4px solid <?= $pColor ?>;">
<div>
<strong><?= htmlspecialchars($p['name']) ?></strong>
<span class="pf-muted-inline"><?= $roleBadge ?></span>
</div>
<form method="post" style="margin:0;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="delete_attached_profile">
<input type="hidden" name="profile_id" value="<?= $p['id'] ?>">
<button type="submit" class="btn-icon-action delete" title="Supprimer" style="background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px;" onclick="return confirm('Supprimer définitivement ce profil ?')">🗑️</button>
</form>
<div style="display: flex; align-items: center; gap: 12px;">
<div class="pf-color-dropdown">
<button type="button" class="pf-color-trigger" style="background-color: <?= $pColor ?>;" title="<?= tr('change_color') ?>" onclick="toggleColorMenu(this)"></button>
<div class="pf-color-menu">
<?php foreach(['#ef4444', '#f97316', '#f59e0b', '#84cc16', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#d946ef', '#f43f5e', '#64748b'] as $c): ?>
<button type="button" class="pf-color-swatch" style="background-color: <?= $c ?>;" onclick="updateAttachedProfileColor(<?= $p['id'] ?>, '<?= $c ?>', this)"></button>
<?php endforeach; ?>
</div>
</div>
<form method="post" style="margin:0;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="delete_attached_profile">
<input type="hidden" name="profile_id" value="<?= $p['id'] ?>">
<button type="submit" class="btn-icon-action delete" title="<?= tr('btn_delete') ?>" style="background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px;" onclick="return confirm('<?= addslashes(tr('confirm_delete_profile')) ?>')">🗑️</button>
</form>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($attached_profiles)): ?>
<div class="pf-muted-note" style="padding: 10px; text-align: center; border: 1px dashed var(--border-light); border-radius: 8px;">Aucun profil rattaché.</div>
<div class="pf-muted-note" style="padding: 10px; text-align: center; border: 1px dashed var(--border-light); border-radius: 8px;"><?= tr('settings_no_attached_profiles') ?></div>
<?php endif; ?>
</div>
<form method="post" style="display: flex; gap: 8px; background: var(--bg-soft); padding: 12px; border-radius: 8px; border: 1px dashed var(--border-light); align-items: flex-end;">
<form method="post" style="display: flex; gap: 8px; background: var(--bg-soft); padding: 12px; border-radius: 8px; border: 1px dashed var(--border-light); align-items: flex-end; flex-wrap: wrap;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="add_attached_profile">
<div class="pf-form-group" style="margin-bottom: 0; flex: 2;">
<label class="pf-label" style="font-size: 0.8rem; margin-bottom: 4px;">Prénom</label>
<input type="text" name="profile_name" class="pf-input" placeholder="Ex: Pol, Carole..." required>
<div class="pf-form-group" style="margin-bottom: 0; flex: 2; min-width: 120px;">
<label class="pf-label" style="font-size: 0.8rem; margin-bottom: 4px;"><?= tr('label_firstname') ?></label>
<input type="text" name="profile_name" class="pf-input" placeholder="<?= tr('placeholder_firstname_ex') ?>" required>
</div>
<div class="pf-form-group" style="margin-bottom: 0; flex: 1.5;">
<label class="pf-label" style="font-size: 0.8rem; margin-bottom: 4px;">Rôle</label>
<div class="pf-form-group" style="margin-bottom: 0; flex: 1.5; min-width: 120px;">
<label class="pf-label" style="font-size: 0.8rem; margin-bottom: 4px;"><?= tr('label_role') ?></label>
<select name="profile_role" class="pf-input" required>
<option value="child">👶 Enfant</option>
<option value="helper">💼 Intervenant</option>
<option value="relative">👵 Proche</option>
<option value="child"><?= tr('role_child_icon') ?></option>
<option value="helper"><?= tr('role_helper_icon') ?></option>
<option value="relative"><?= tr('role_relative_icon') ?></option>
</select>
</div>
<button type="submit" class="pf-btn pf-shrink-0" style="padding: 8px 16px;">Ajouter</button>
<div class="pf-form-group" style="margin-bottom: 0; display: flex; flex-direction: column; align-items: flex-start;">
<label class="pf-label" style="font-size: 0.8rem; margin-bottom: 4px;"><?= tr('label_color') ?></label>
<div style="display: flex; gap: 4px; flex-wrap: wrap; padding-bottom: 4px;">
<?php foreach(['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#64748b'] as $i => $c): ?>
<label class="pf-radio-swatch" style="background-color: <?= $c ?>;" title="<?= $c ?>">
<input type="radio" name="profile_color" value="<?= $c ?>" <?= $i === 0 ? 'checked' : '' ?> style="display:none;">
</label>
<?php endforeach; ?>
</div>
</div>
<button type="submit" class="pf-btn pf-shrink-0" style="padding: 8px 16px; margin-bottom: 2px;"><?= tr('btn_add') ?></button>
</form>
</section>
@@ -632,7 +719,7 @@ require __DIR__ . '/header.php';
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
<div class="pf-settings-admin-link">
<a href="/admin/">→ Panneau d'administration</a>
<a href="/admin/"><?= tr('set_link_admin') ?></a>
</div>
<?php endif; ?>
@@ -660,7 +747,7 @@ function copyCode() {
});
}
// --- GESTION DES NOTIFICATIONS TOAST (Remplaçant l'ancien bandeau vert) ---
// --- GESTION DES NOTIFICATIONS TOAST ---
document.addEventListener('DOMContentLoaded', () => {
<?php if ($success): ?>
if (typeof showToast === 'function') {
@@ -678,6 +765,70 @@ document.addEventListener('DOMContentLoaded', () => {
}
<?php endif; ?>
});
function selectProfileColor(btn, hexColor) {
// 1. Mettre à jour l'input caché
document.getElementById('profile_color').value = hexColor;
// 2. Mettre à jour l'UI (enlever la classe selected partout, l'ajouter sur le bouton cliqué)
const swatches = document.querySelectorAll('#color-palette .pf-color-swatch');
swatches.forEach(el => el.classList.remove('selected'));
btn.classList.add('selected');
}
function toggleColorMenu(btn) {
// Fermer tous les autres popovers
document.querySelectorAll('.pf-color-menu').forEach(menu => {
if (menu !== btn.nextElementSibling) menu.classList.remove('is-open');
});
// Ouvrir/Fermer celui cliqué
btn.nextElementSibling.classList.toggle('is-open');
}
// Fermer le menu si on clique en dehors
document.addEventListener('click', (e) => {
if (!e.target.closest('.pf-color-dropdown')) {
document.querySelectorAll('.pf-color-menu').forEach(menu => menu.classList.remove('is-open'));
}
});
// --- MISE À JOUR DYNAMIQUE COULEUR PROFIL RATTACHÉ ---
async function updateAttachedProfileColor(profileId, newColor, btnElement) {
const fd = new FormData();
fd.append('action', 'update_attached_profile_color');
fd.append('profile_id', profileId);
fd.append('color', newColor);
fd.append('csrf_token', window.CSRF_TOKEN); // Sécurité absolue
try {
const res = await pachaFetch('/settings.php', { method: 'POST', body: fd });
if (res && res.success) {
if (typeof showToast === 'function') {
showToast(window.I18N['color_updated'] || 'Couleur mise à jour !', 'success');
}
const dropdown = btnElement.closest('.pf-color-dropdown');
if (dropdown) {
// Màj visuelle de la pastille déclencheur
dropdown.querySelector('.pf-color-trigger').style.backgroundColor = newColor;
dropdown.querySelector('.pf-color-menu').classList.remove('is-open');
}
// Màj de la bordure gauche de la carte
const card = btnElement.closest('.pf-member-card');
if (card) {
card.style.borderLeftColor = newColor;
}
} else {
if (typeof showToast === 'function') showToast(window.I18N['error_occured'] || 'Erreur', 'error');
}
} catch (err) {
console.error(err);
if (typeof showToast === 'function') showToast(window.I18N['error_occured'] || 'Erreur', 'error');
}
}
</script>
<?php require __DIR__ . '/footer.php'; ?>