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 37626 additions and 4293 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,17 +185,32 @@ 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>
</form>
<!-- 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>
<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>
<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>
</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');
+39 -32
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';
$envHost = getenv('DB_HOST');
// 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';
$db = $_SESSION['family_db'] ?? null;
if (!$db) {
$current = basename($_SERVER['PHP_SELF'] ?? '');
if (!in_array($current, ['login.php', 'register.php'])) {
header('Location: /login.php');
exit;
}
return;
} else {
// XAMPP
$host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
$user = 'root';
$pass = '';
}
try {
$pdo = new PDO(
"mysql:host=$host;dbname=$db;charset=utf8mb4",
$user, $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,
]
);
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
];
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');
}
// ------------------------------------------------
$pdo = new PDO($dsn, $user, $pass, $options);
// 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());
}
?>
+362 -298
View File
@@ -1,28 +1,23 @@
<?php
// Dictionnaire Catalan
// Dictionnaire Catalan - HouseHub
return [
// ==========================================
// TEST AUTO
// ==========================================
'test_running' => 'Proves en curs...',
'test_passed' => 'Prova superada',
'test_failed' => 'Prova fallida',
'tests_title' => 'Laboratori de Proves',
'tests_run_budget' => 'Provar el Pressupost',
'tests_run_all' => 'Iniciar totes les proves',
'tests_copy_report' => 'Copiar l\'informe',
'tests_waiting' => 'Esperant l\'inici...',
'tests_report_copied' => 'Informe copiat al porta-retalls!',
'tests_live_preview' => 'Previsualització en viu',
// ==========================================
// GLOBAL & NAVIGATION
// 1. GLOBAL, NAVIGATION & ACTIONS
// ==========================================
'menu_home' => 'Inici',
'menu_calendar' => 'Calendari',
'menu_budget' => 'Pressupost',
'menu_holidays' => 'Viatges',
'menu_gifts' => 'Regals',
'menu_garage' => 'Garatge',
'menu_memo' => 'Notes',
'menu_todo' => 'Todo',
'menu_liste' => 'Llistes',
'menu_groceries' => 'Compra',
'menu_calendar_ios' => 'Calendari iOS',
'menu_printvault' => 'PrintVault',
'menu_planka' => 'Planka',
'nav_home' => 'Inici',
'nav_calendar' => 'Calendari',
'nav_budget' => 'Pressupost',
@@ -30,7 +25,6 @@ return [
'nav_gifts' => 'Regals',
'aria_menu' => 'Menú mòbil',
// Boutons et actions globales
'actions' => 'Accions',
'edit' => 'Modificar',
'delete' => 'Suprimir',
@@ -40,28 +34,33 @@ return [
'btn_cancel' => 'Cancel·lar',
'btn_delete' => 'Eliminar',
'btn_close' => 'Tancar',
'btn_add' => 'Afegir',
'btn_back' => '◀ Tornar',
'btn_edit_bases' => '⚙️ Modificar les bases',
'btn_edit' => 'Modificar',
'error_occured' => 'S\'ha produït un error.',
// Mots génériques & Erreurs
'error' => 'Error.',
'error_occured' => 'S\'ha produït un error.',
'error_network' => 'Error de xarxa',
'error_generic' => 'S\'ha produït un error',
'confirm_delete' => 'Estàs segur que vols suprimir aquest element?',
'loading' => 'Carregant...',
'date' => 'Data',
'settings' => 'Configuració',
// Mois & Jours
'month_01' => 'Gener', 'month_02' => 'Febrer', 'month_03' => 'Març',
'month_04' => 'Abril', 'month_05' => 'Maig', 'month_06' => 'Juny',
'month_07' => 'Juliol','month_08' => 'Agost', 'month_09' => 'Setembre',
'month_10' => 'Octubre','month_11' => 'Novembre','month_12' => 'Desembre',
'day_mon' => 'Dl', 'day_tue' => 'Dt', 'day_wed' => 'Dc',
'day_thu' => 'Dj', 'day_fri' => 'Dv', 'day_sat' => 'Ds', 'day_sun' => 'Dg',
// Mesos & Dies
'month_01' => 'Gener', 'month_02' => 'Febrer', 'month_03' => 'Març', 'month_04' => 'Abril', 'month_05' => 'Maig', 'month_06' => 'Juny',
'month_07' => 'Juliol','month_08' => 'Agost', 'month_09' => 'Setembre', 'month_10' => 'Octubre','month_11' => 'Novembre','month_12' => 'Desembre',
'month_jan' => 'Gener', 'month_feb' => 'Febrer', 'month_mar' => 'Març', 'month_apr' => 'Abril', 'month_may' => 'Maig', 'month_jun' => 'Juny',
'month_jul' => 'Juliol', 'month_aug' => 'Agost', 'month_sep' => 'Setembre', 'month_oct' => 'Octubre', 'month_nov' => 'Novembre', 'month_dec' => 'Desembre',
'day_mon' => 'Dl', 'day_tue' => 'Dt', 'day_wed' => 'Dc', 'day_thu' => 'Dj', 'day_fri' => 'Dv', 'day_sat' => 'Ds', 'day_sun' => 'Dg',
// ==========================================
// AUTHENTIFICATION / LOGIN
// 2. AUTHENTICATION / LOGIN
// ==========================================
'login_title' => 'Connexió - HouseHub',
'login_header' => 'Connexió',
'login_subtitle' => 'Si us plau, identifiqueu-vos per continuar',
'label_username' => "Nom d'usuari",
'label_password' => 'Contrasenya',
'placeholder_username' => 'ex: pacha',
@@ -69,18 +68,19 @@ return [
'btn_login_submit' => 'Connectar-se',
'logout' => 'Tancar sessió',
'btn_logout' => 'Tancar sessió',
'login_remember_me' => "Recorda'm",
'error_missing_fields' => 'Camps obligatoris.',
'error_invalid_credentials' => 'Identificadors incorrectes.',
'login_subtitle' => 'Si us plau, identifiqueu-vos per continuar',
// ==========================================
// ACCUEIL / INDEX
// 3. HOME & DASHBOARD
// ==========================================
'home_title' => 'HouseHub - Inici',
'home_welcome' => 'Benvinguts a HouseHub',
'home_subtitle' => "Centre de control de l'organització familiar.",
'home_logged_as' => 'Connectat com a',
'home_modules_title' => 'Mòduls',
'mod_calendar_name' => 'Calendari Familiar',
'mod_calendar_desc' => "Gestioneu les vacances, les modalitats de cura i l'organització setmanal.",
'mod_holidays_name' => 'Vacances',
@@ -93,52 +93,19 @@ return [
'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Els vostres memos, receptes, instruccions i informació diversa amb Markdown.',
'menu_memo' => 'Notes',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Gestioneu les vostres tasques, llistes de la compra i recordatoris familiars.',
'mod_calendar_ios_name' => 'Calendari iOS',
'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).',
'menu_todo' => 'Todo',
'mod_liste_name' => 'Llistes',
'mod_liste_desc' => 'Llistes compartides — compres, bricolatge, farmàcia… Tantes llistes com convingui.',
'mod_groceries_name' => 'Compra',
'mod_groceries_desc' => 'Llista compartida: afegiu productes i marqueu-los quan siguin al carretó.',
'menu_groceries' => 'Compra',
'groceries_placeholder_add' => 'Ex : llet, pa, tomàquets…',
'groceries_btn_add' => 'Afegir',
'groceries_btn_uncheck_all' => 'Tornar a marcar com a pendents',
'groceries_btn_delete_picked' => 'Eliminar els marcats',
'groceries_subtitle' => '{pending} pendents · {cart} al carretó',
'groceries_added' => 'Afegit',
'groceries_updated' => 'Actualitzat',
'groceries_deleted' => 'Eliminat',
'groceries_reset_next' => 'Llista llesta per a la propera compra',
'groceries_picked_removed' => 'Articles eliminats',
'groceries_label_empty' => 'El text no pot ser buit',
'groceries_confirm_del_title' => 'Eliminar?',
'groceries_confirm_del_body' => 'Voleu treure aquest producte de la llista?',
'groceries_confirm_uncheck_title' => 'Tornar a marcar com a pendents',
'groceries_confirm_uncheck_body' => 'Es netejaran les marques del carretó. Els productes es mantindran a la llista.',
'groceries_confirm_clear_title' => 'Eliminar els marcats',
'groceries_confirm_clear_body' => 'Tots els articles marcats se suprimiran de la llista.',
'groceries_empty' => 'Encara no hi ha res. Afegiu un producte a dalt.',
'groceries_aria_toggle' => 'Marcar com al carretó o pendent',
'groceries_section_pending' => 'Pendents',
'groceries_section_cart' => 'Al carretó',
'groceries_prompt_edit' => 'Editar el producte',
'groceries_btn_clear_list' => 'Buidar la llista',
'groceries_confirm_empty_list_title' => 'Buidar tota la llista?',
'groceries_confirm_empty_list_body' => 'Es trauran tots els productes. Romandran a l’historial per tornar-los a afegir ràpidament.',
'groceries_list_cleared' => 'Llista buidada',
'groceries_history_title' => 'Historial — un clic per afegir',
'groceries_history_empty' => 'L’historial es va omplint amb els productes que afegiu (fins al límit definit als paràmetres).',
'groceries_history_meta' => 'Fins a {max} productes · ',
'groceries_settings_link' => 'Paràmetres',
'groceries_settings_title' => 'Compra — historial',
'groceries_settings_intro' => 'Nombre màxim de productes recordats a l’historial (sense duplicats, sense distingir majúscules).',
'groceries_settings_history_max' => 'Mida de l’historial',
'groceries_settings_history_hint' => 'Entre 1 i 50. Les entrades més antigues se suprimeixen quan es supera el límit.',
'groceries_settings_saved' => 'Preferències de la compra desades.',
'groceries_settings_error' => 'No s’ha pogut desar:',
'groceries_err_duplicate' => 'Aquest producte ja és a la llista.',
'mod_calendar_ios_name' => 'Calendari iOS',
'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).',
'mod_printvault_name'=> 'PrintVault',
'mod_printvault_desc'=> 'Gestioneu els vostres fitxers d\'impressió 3D — STL, 3MF, GCode.',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Gestioneu els vostres projectes en mode Kanban amb Planka.',
'cta_open' => 'Obrir',
'cta_explore' => 'Explorar',
'cta_view_lists' => 'Desembolicar',
@@ -148,15 +115,52 @@ return [
'cta_check' => 'Marcar',
'cta_cart' => 'Anar a comprar',
'cta_sync' => 'Sincronitzar',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Gestioneu els vostres fitxers d\'impressió 3D — STL, 3MF, GCode.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Imprimir',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Gestioneu els vostres projectes en mode Kanban amb Planka.',
'menu_planka' => 'Planka',
// --- SETTINGS : ENFANTS ---
// ==========================================
// 4. SETTINGS
// ==========================================
'set_title_page' => '⚙️ Configuració',
'set_title_lang' => '🌐 Idioma',
'set_title_modules' => '🧩 Mòduls Actius',
'set_desc_modules' => 'Tria els mòduls visibles a la navegació (compartits amb tots els membres de l\'espai).',
'set_title_ios' => '📱 Integració de Calendari iOS (CalDAV)',
'set_desc_ios' => 'Configura aquí el teu calendari iCloud per sincronitzar els esdeveniments creats a HouseHub.',
'set_label_apple_id' => 'ID d\'Apple (correu iCloud)',
'set_label_apple_pwd' => 'Contrasenya d\'aplicació d\'Apple',
'set_label_apple_url' => 'URL del calendari CalDAV',
'set_btn_test_ios' => 'Prova la connexió',
'set_btn_disconnect_ios' => 'Desconnecta',
'set_title_bg' => '🖼️ Imatge d\'Inici',
'set_desc_bg' => 'Foto de fons mostrada a la pàgina d\'inici (compartida amb tots els membres).',
'set_badge_custom_bg' => 'Imatge personalitzada',
'set_label_new_bg' => 'Nova imatge (jpg, png, webp — màx 10 MB)',
'set_btn_reset_bg' => '🔄 Restableix la imatge per defecte',
'set_confirm_reset_bg' => 'Tornar a la imatge per defecte?',
'set_title_profile' => '👤 El meu Perfil',
'set_label_username' => 'Nom d\'usuari',
'set_label_display_name' => 'Nom / Àlies mostrat',
'set_label_profile_color' => 'Color temàtic del meu perfil',
'set_desc_profile_color' => 'Aquest color s\'utilitzarà per a les teves graelles de pressupost, calendaris i indicadors personals.',
'set_title_pwd' => '🔒 Canviar Contrasenya',
'set_label_cur_pwd' => 'Contrasenya actual',
'set_label_new_pwd' => 'Nova contrasenya',
'set_label_confirm_pwd' => 'Confirma',
'set_btn_edit' => 'Modifica',
'set_title_family' => '🏠 El meu Espai Familiar',
'set_label_family_name' => 'Nom de l\'espai',
'set_desc_invite' => 'Codi d\'invitació — comparteix-lo per convidar algú',
'set_title_copy' => 'Fes clic per copiar',
'set_msg_copied' => '✓ Copiat!',
'set_btn_new_code' => '🔄 Nou codi',
'set_confirm_new_code' => 'Regenerar codi? L\'antic serà invalidat.',
'set_desc_register_link' => 'Enllaç de registre:',
'set_title_members' => 'Membres',
'set_badge_admin' => 'Admin',
'set_title_attached' => '🧸 Perfils Adjunts',
'set_desc_attached' => 'Membres gestionats per la llar sense accés directe.',
'set_empty_attached' => 'Cap perfil adjunt.',
'set_link_admin' => '→ Tauler d\'Administració',
'set_kids_title' => 'Nens de la llar',
'set_kids_desc' => 'Gestioneu la llista de nens per associar-los a l\'estalvi, als regals, etc.',
'set_add_kid_placeholder' => 'Nom del nen',
@@ -168,10 +172,66 @@ return [
'set_global_params' => 'Paràmetres globals',
'set_care_modes' => 'Modalitats de cura habituals',
'set_add_care_mode' => 'Afegir una modalitat',
'btn_save' => 'Desar',
// Labels Profiles
'label_firstname' => 'Nom',
'placeholder_firstname_ex' => 'Ex: Pol, Carole...',
'label_role' => 'Rol',
'role_child' => '👶 Nen/a',
'role_child_icon' => '👶 Nen/a',
'role_helper' => '💼 Ajudant',
'role_helper_icon' => '💼 Ajudant',
'role_relative' => '👵 Familiar',
'role_relative_icon' => '👵 Familiar',
'role_unknown' => 'Desconegut',
'label_color' => 'Color',
'change_color' => 'Canviar el color',
'confirm_delete_profile' => 'Voleu eliminar definitivament aquest perfil?',
// Settings Messages
'error_csrf' => 'Sessió invàlida (CSRF). Si us plau, recarrega la pàgina.',
'set_err_min_module' => 'Has de mantenir almenys un mòdul actiu.',
'set_success_modules' => 'Mòduls actualitzats amb èxit.',
'set_success_lang' => 'Idioma actualitzat.',
'set_err_empty_name' => 'El nom no pot estar buit.',
'set_success_profile' => 'Perfil actualitzat amb èxit.',
'set_err_wrong_pwd' => 'Contrasenya actual incorrecta.',
'set_err_pwd_length' => 'La nova contrasenya ha de tenir almenys 6 caràcters.',
'set_err_pwd_match' => 'Les contrasenyes no coincideixen.',
'set_success_pwd' => 'Contrasenya canviada amb èxit.',
'set_success_family' => 'Nom de l\'espai actualitzat.',
'set_success_invite' => 'Nou codi d\'invitació generat.',
'set_err_bg_format' => 'Format no compatible (jpg, png, webp).',
'set_success_bg' => 'Imatge d\'inici actualitzada.',
'set_err_upload' => 'Error durant la càrrega.',
'set_success_bg_reset' => 'Imatge d\'inici restablerta.',
'set_err_db_unavailable' => 'Base de dades familiar no disponible.',
'set_err_ios_missing' => 'Si us plau, proporciona un ID d\'iCloud, contrasenya d\'aplicació i URL CalDAV.',
'set_success_ios' => 'Connexió de calendari iOS desada.',
'set_success_ios_auto' => ' URL del calendari detectada automàticament.',
'set_err_ios_save' => 'No es pot desar la connexió iOS: ',
'set_err_ios_none' => 'Cap connexió iOS configurada.',
'set_success_ios_test' => 'Connexió iCloud CalDAV vàlida (HTTP %s).',
'set_err_ios_test' => 'La prova de connexió ha fallat (HTTP %s). Revisa les teves credencials.',
'set_err_ios_test_fail' => 'Prova de connexió no disponible: ',
'set_success_ios_del' => 'Connexió iOS eliminada.',
'set_success_profile_add' => 'Perfil de %s afegit amb èxit.',
'set_err_name_required' => 'El nom és obligatori.',
'set_success_profile_del' => 'Perfil suprimit.',
'set_success_color' => 'Color actualitzat!',
'set_success_color_profile' => 'Color del perfil actualitzat.',
'set_success_color_update' => 'Color actualitzat!',
'color_updated' => 'Color actualitzat!',
'settings_profile_added' => 'Perfil de %s afegit amb èxit.',
'settings_err_name_required' => 'El nom és obligatori.',
'settings_attached_profiles_title' => 'Perfils adjunts',
'settings_attached_profiles_desc' => 'Membres de la llar sense accés directe.',
'settings_no_attached_profiles' => 'Cap perfil adjunt.',
'placeholder_apple_id' => 'nom@icloud.com',
'placeholder_apple_pwd' => 'xxxx-xxxx-xxxx-xxxx',
// ==========================================
// FAMILY CALENDAR
// 5. FAMILY CALENDAR
// ==========================================
'fc_page_title' => 'HouseHub - Calendari',
'fc_main_header' => 'Calendari Familiar',
@@ -196,6 +256,7 @@ return [
'fc_weekly_planning' => 'Planificació setmanal',
'fc_col_month' => 'Mes',
'fc_col_week_short' => 'Set.',
'fc_col_week' => 'Setmana',
'fc_col_av' => 'Av.',
'fc_col_use' => 'Use',
'fc_legend_title' => 'Llegenda',
@@ -206,11 +267,12 @@ return [
'fc_unit_days' => 'dies',
'fc_menu_carole' => 'Permisos Carole',
'fc_clear' => 'Esborrar',
'fc_menu_kids_leaves' => 'Permisos nens',
'fc_menu_kids_leaves' => 'Permisos pares',
'fc_err_save_snap' => 'Error en desar el correctiu.',
'fc_err_fetch_gov' => 'Error en la connexió amb l\'API governamental.',
'fc_err_no_data_gov' => 'No s\'han trobat dades a l\'API del govern per a aquest any.',
'fc_err_action' => 'Error en l\'acció: ',
'fc_err_select_leave' => 'Si us plau, selecciona un tipus de permís.',
'fc_today_short' => 'Avui',
'fc_today_title' => 'Anar a avui',
'fc_tab_foyer' => '🏡 Llar',
@@ -218,91 +280,48 @@ return [
'fc_select_member' => 'Selecciona un membre de la família:',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Nen',
'fc_care_modes_title' => '👶 Modes de cura associats',
'fc_care_modes_desc' => 'Marca els modes de cura que s\'apliquen a aquest nen.',
'fc_no_care_mode' => 'Cap mode configurat.',
'fc_matrix_title' => '👨‍👩‍👦 Matriu de Drets de Permís',
'fc_matrix_desc' => 'Declara els codis de comptadors i les dates de renovació.',
'fc_matrix_code' => 'Codi',
'fc_matrix_date' => 'Data de Reset',
'fc_matrix_empty' => 'Cap comptador configurat.',
'fc_matrix_saved' => 'Matriu de drets actualitzada! 💼',
'fc_child_saved' => 'Subscripció del nen desada!',
'btn_add_counter' => '➕ Afegir',
'btn_save_rights' => '💾 Desar',
'fc_modal_settings_title' => 'Configuració del Calendari',
'fc_zone_label' => 'Zona Escolar (Vacances)',
'fc_zone_desc' => 'Determina el calendari automàtic de vacances escolars.',
'fc_care_modes_label' => 'Modes de Cura de la Llar',
'fc_care_modes_desc' => 'Gestiona la llista global de modes de cura possibles per a la família.',
'fc_add_mode_placeholder' => 'Ex: Mainadera, Casal, Avis...',
'fc_settings_updated' => 'Configuració actualitzada correctament!',
'fc_first_day' => 'Primer dia de la setmana',
// --- CALENDARI : PARÀMETRES (MEMBRES I VISUALITZACIÓ) ---
'fc_tab_display' => '🖥️ Visualització',
'fc_display_title' => 'Ajustos de visualització',
'fc_display_desc' => 'Personalitza l\'aparença i el comportament predeterminat del teu calendari.',
'fc_default_view' => 'Vista per defecte',
'fc_view_month' => '1 mes',
'fc_view_2months' => '2 mesos',
'fc_view_3months' => '3 mesos',
'fc_working_hours' => 'Horari de cura (Per defecte)',
'fc_working_hours_desc' => 'Exemple: 08:30-18:00',
'fc_role_unknown' => 'Desconegut',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Nen/a',
'fc_role_helper' => 'Ajudant',
'fc_error' => 'Error: ',
'fc_role_unknown' => 'Desconegut',
'fc_care_modes_title' => 'Modes de Cura Predeterminats',
'fc_care_modes_desc' => 'Quins modes de cura s\'apliquen a aquest nen/a?',
'fc_no_care_mode' => 'No hi ha modes de cura configurats a la llar.',
'fc_child_saved' => 'Desat!',
'fc_matrix_title' => 'Matriu de Permisos',
'fc_matrix_desc' => 'Defineix les quotes i dates de renovació.',
'fc_matrix_title' => 'Matriu de Drets de Permís',
'fc_matrix_desc' => 'Declara els codis de comptadors i les dates de renovació.',
'fc_matrix_code' => 'Codi',
'fc_matrix_date' => 'Data de Reset',
'fc_matrix_empty' => 'No hi ha comptadors definits.',
'fc_matrix_saved' => 'Matriu desada!',
'fc_leave_obsolete' => 'Obsolet',
'fc_leave_code_placeholder' => 'Codi...',
'fc_leave_method_fixed' => 'Fix',
'fc_leave_method_accumulated' => 'Acumulat',
'fc_date_format_placeholder' => 'DD/MM',
'fc_date_format_title' => 'Format DD/MM',
'fc_table_code' => 'Codi',
'fc_table_method' => 'Mètode',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renov.',
'fc_matrix_saved' => 'Matriu de drets actualitzada! 💼',
'fc_child_saved' => 'Desat!',
'btn_add_counter' => '➕ Afegir',
'btn_save_rights' => 'Guardar',
// ==========================================
// MODAL DE CONFIGURACIÓ DEL CALENDARI
// ==========================================
'fc_modal_settings_title' => 'Configuració del calendari',
'fc_tab_foyer' => 'Llar',
'fc_tab_members' => 'Membres i Drets',
// Llar: Zona escolar
'fc_modal_settings_title' => 'Configuració del Calendari',
'fc_zone_label' => 'Zona escolar',
'fc_zone_a' => 'Zona A',
'fc_zone_b' => 'Zona B',
'fc_zone_c' => 'Zona C',
'fc_zone_other' => 'Altra / Fora de França',
'fc_zone_desc' => 'Permet mostrar correctament les vacances escolars.',
// Llar: Opcions de cura (Modes de garde)
'fc_care_modes_label' => 'Modalitats de cura',
'fc_add_mode_placeholder' => 'Ex: Mainadera, Llar d\'infants...',
'fc_care_modes_desc' => 'Afegeix les diferents modalitats de cura per als teus fills.',
'fc_empty_kids_msg' => 'Actualment no hi ha cap infant configurat a la teva família.',
'fc_link_settings' => 'Anar a la configuració general',
// Llar: Catàleg de permisos (Congés)
'fc_settings_updated' => 'Configuració actualitzada correctament!',
'fc_first_day' => 'Primer dia de la setmana',
'fc_helper_recap' => 'Resum Interventor',
'fc_no_helper_leaves' => 'Cap permís sol·licitat aquest mes.',
'fc_col_date' => 'Data',
'fc_col_type' => 'Tipus',
'fc_col_duration' => 'Durada',
'fc_full_year' => 'Any sencer',
'fc_custom_period' => 'Període específic...',
'fc_worked_days' => 'Treballats (Est.)',
'fc_tab_display' => '🖥️ Visualització',
'fc_display_title' => 'Ajustos de visualització',
'fc_display_desc' => 'Personalitza l\'aparença i el comportament predeterminat del teu calendari.',
'fc_default_view' => 'Vista per defecte',
'fc_working_hours' => 'Horari de cura (Per defecte)',
'fc_working_hours_desc' => 'Exemple: 08:30-18:00',
'fc_error' => 'Error: ',
'fc_leave_catalog_title' => 'Catàleg de permisos',
'fc_leave_catalog_desc' => 'Defineix els tipus de permisos possibles per a tota la família.',
'fc_add_leave_type' => 'Afegir un tipus de permís',
@@ -312,24 +331,34 @@ return [
'fc_leave_label_ph' => 'Ex: Vacances, Assumptes propis...',
'fc_quota_default' => 'Quota per defecte',
'fc_reset_month' => 'Mes de renovació',
'fc_leave_obsolete' => 'Obsolet',
'fc_leave_code_placeholder' => 'Codi...',
'fc_leave_method_fixed' => 'Fix',
'fc_leave_method_accumulated'=> 'Acumulat',
'fc_date_format_placeholder'=> 'DD/MM',
'fc_date_format_title' => 'Format DD/MM',
'fc_table_code' => 'Codi',
'fc_table_method' => 'Mètode',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renov.',
'fc_label_carry_over' => 'Inclou dies reportats (Caducables) ⚠️',
'fc_carry_over_note' => 'Part del saldo global que caducarà a la data límit.',
'fc_carry_max' => 'Màx Reportable',
'fc_carry_deadline' => 'Mes de Caducitat',
'fc_deadline_none' => 'Cap',
'fc_allowance_help' => 'Fix: Quota anual. Gradual: Afegit per mes.',
'fc_carry_max_short' => 'Report màx',
'fc_edit_leave' => '✏️ Modificar:',
'fc_not_modifiable' => 'No modificable',
'fc_add_leave' => '+ Afegir',
'fc_err_code_label' => 'El Codi i l\'Etiqueta són obligatoris.',
'fc_catalog_updated' => 'Catàleg actualitzat!',
'fc_balance_corrected' => 'Saldo corregit!',
'fc_no_leave_configured' => 'Cap permís configurat.',
'fc_confirm_del_catalog' => 'Voleu eliminar definitivament el permís %s del catàleg? Això també el retirarà dels membres que l\'utilitzen.',
'fc_confirm_del_member_leave'=> 'Estàs segur que vols retirar aquest permís per a aquest membre?',
// Membres
'fc_select_member' => 'Selecciona un membre:',
// Mesos
'month_jan' => 'Gener',
'month_feb' => 'Febrer',
'month_mar' => 'Març',
'month_apr' => 'Abril',
'month_may' => 'Maig',
'month_jun' => 'Juny',
'month_jul' => 'Juliol',
'month_aug' => 'Agost',
'month_sep' => 'Setembre',
'month_oct' => 'Octubre',
'month_nov' => 'Novembre',
'month_dec' => 'Desembre',
// Llegendes
'leg_presence' => 'Presència Pep',
'leg_school_holidays' => 'Vacances Escolars',
'leg_public_holiday' => 'Festiu',
@@ -337,11 +366,10 @@ return [
'leg_extra_off' => 'Extra Off',
'leg_centre' => 'Casal',
'leg_avis' => 'Avís',
'leg_pep_sick' => 'Pep Malalt',
'fc_menu_sick' => 'Malalt',
'care_nounou' => 'Kangur',
'care_centre' => 'Casal',
'care_avis' => 'Avis',
'vac_toussaint' => 'Vacances de Tots Sants',
'vac_noel' => 'Vacances de Nadal',
'vac_hiver' => "Vacances d'Hivern",
@@ -350,12 +378,11 @@ return [
'vac_ete' => "Vacances d'Estiu",
'btn_off' => 'Off',
'btn_extra' => 'Extra',
'fc_alert_burn_days' => '⚠️ Risc de pèrdua : %s d a gastar abans del %s',
'fc_alert_burn_days' => '⚠️ Risc de pèrdua : %s d a gastar abans del final del cicle',
'fc_alert_burn_jra' => '⚠️ JRA: Només 2d es poden reportar després de febrer!',
// ==========================================
// HOLIDAYS (VOYAGES)
// 6. HOLIDAYS (VOYAGES)
// ==========================================
'hdl_title_list' => 'HouseHub - Les meves vacances',
'hdl_title_detail' => 'HouseHub - Detall del viatge',
@@ -430,9 +457,6 @@ return [
'hdl_to_place' => 'Per situar',
'hdl_from' => 'Des del',
'hdl_to' => 'Fins al',
'hdl_btn_add_step' => 'Afegir una etapa',
'hdl_quick_edit_title' => 'Modificació ràpida',
'hdl_paid' => 'Pagat',
'weather_forecast' => 'Previsions meteorològiques',
'weather_not_available' => 'Temps no disponible',
'temp_max' => 'Temp. Màxima',
@@ -447,7 +471,7 @@ return [
'hdl_default_exp_name' => 'Despesa',
'hdl_select_beneficiary' => '-- Seleccionar --',
// Anciennes clés Holidays non préfixées (conservades)
// Anciennes clés (conservades)
'outbound_trip' => 'Anada',
'return_trip' => 'Tornada',
'period' => 'Període',
@@ -500,7 +524,7 @@ return [
'to_place' => '📥 A col·locar',
// ==========================================
// BUDGET (GLOBAL & CATEGORIES)
// 7. BUDGET (GLOBAL, SUIVI, PREV, EPARGNE, PROVISIONS)
// ==========================================
'budget_page_title' => 'HouseHub - Pressupost',
'budget_main_header' => 'Gestió del Pressupost',
@@ -508,9 +532,16 @@ return [
'budget_tab_prev' => 'Pressupost 2026',
'budget_tab_savings' => 'Estalvis',
'budget_tab_recap' => 'Resum',
'budget_tab_provisions' => 'Provisions',
'budget_tab_kids' => 'Nens',
'budget_err_file_not_found' => 'Vaja! Fitxer no trobat.',
'budget_err_file_detail' => 'Comproveu que el fitxer views/%s.php existeix.',
'budget_err_not_authorized' => 'Pàgina no autoritzada.',
'bud_err_tech' => 'S\'ha produït un error tècnic.',
'bud_err_server' => 'Error del servidor: ',
'bud_err_delete' => 'Error en suprimir.',
// Categories
'cat_income' => 'Ingressos',
'cat_fmcg' => 'Compres (FMCG)',
'cat_fuel' => 'Benzina',
@@ -518,12 +549,14 @@ return [
'cat_fixed' => 'Despeses Fixes',
'cat_others' => 'Altres / Imprevistos',
'cat_savings' => 'Estalvis',
'budget_tab_kids' => 'Nens',
// Settings
'budget_settings_title' => 'Configuració del Pressupost',
'bs_tab_accounts' => '💳 Comptes',
'bs_tab_categories' => '🏷️ Categories',
'bs_tab_rules' => '🤖 Regles d\'importació',
'bs_tab_salaries' => '💼 Salaris',
'bs_tab_csv' => '📥 Importació CSV',
'bs_empty_accounts' => 'Cap compte configurat.',
'bs_type_checking' => 'Corrent',
'bs_type_savings' => 'Estalvi',
@@ -553,7 +586,23 @@ return [
'bs_currency_label' => 'Divisa de la llar',
'bs_currency_desc' => 'Divisa principal utilitzada per mostrar els vostres comptes i pressupostos.',
'bs_currency_updated' => 'Divisa actualitzada correctament!',
// ONGLET CSV MAPPING
'bud_settings_dyn_title' => 'Estimacions Dinàmiques (Nens)',
'bud_dyn_nanny_fixed' => 'Sou fix Cangur (€)',
'bud_dyn_nanny_daily' => 'Despeses diàries de manteniment (€)',
'bud_dyn_nanny_aid' => 'Ajuda del govern (€)',
'bud_dyn_nanny_cesu_avg' => 'Mitjana CESU pagada (€)',
'bud_dyn_school_meal' => 'Preu del menjador (€)',
'bud_dyn_school_aftercare' => 'Preu acollida tarda (€)',
'bud_dyn_school_fullday' => 'Preu acollida dia sencer (Dimecres/Vacances) (€)',
'bud_dyn_pattern_title' => 'Setmana escolar tipus',
'bud_audit_month' => 'Mes analitzat: %02d/%d',
'bud_audit_nanny_abs' => 'Absències %s deduïdes : %dd',
'bud_audit_child' => '👦 %s :',
'bud_audit_school_fee' => 'Menjador (%dd) + Acollida (%dd) + Casal (%dd) = %.2f€',
'bud_audit_sick' => 'Dies de malaltia deduïts : %dd',
'bud_audit_nanny_full' => '💼 %s : Fix (%.2f€) + Despeses (%dd x %.2f€ = %.2f€) - Ajut (%.2f€) - CESU (%.2f€) = %.2f€',
// CSV
'bs_csv_title' => 'Format de la importació CSV',
'bs_csv_desc' => 'Configureu aquí les columnes que corresponen a l\'exportació del vostre banc.',
'bs_csv_delimiter' => 'Separador',
@@ -574,7 +623,6 @@ return [
'bs_csv_col_ref' => 'Núm Col: Referència única (Opcional)',
'btn_save_format' => 'Desar el format',
'bs_csv_saved' => 'Format CSV desat',
'bs_tab_csv' => '📥 Importació CSV',
'bs_csv_settings_title' => 'Configuració del format',
'bs_csv_drop_title' => 'Arrossegueu el vostre fitxer CSV aquí',
'bs_csv_drop_desc' => 'o feu clic per cercar fitxers',
@@ -584,7 +632,22 @@ return [
'bs_csv_empty_col' => 'Buit',
'bs_csv_no_data' => 'No s\'ha trobat cap línia de transacció en aquest fitxer.',
// --- BUDGET : SUIVI ---
// Onboarding
'bs_onb_title' => 'Benvinguts! Configurem el teu pressupost:',
'bs_onb_desc' => 'Completa aquests passos perquè l\'aplicació pugui calcular el teu pressupost disponible:',
'bs_onb_cat_link' => 'Categories',
'bs_onb_cat_desc' => 'Crea almenys una categoria de despeses.',
'bs_onb_acc_link' => 'Comptes Bancaris',
'bs_onb_acc_desc' => 'Afegeix almenys un compte corrent o d\'estalvi.',
'bs_onb_sal_link' => 'Sous',
'bs_onb_sal_desc' => 'Introdueix els ingressos de l\'any en curs.',
'bs_onb_items_link' => 'Línies de Pressupost',
'bs_onb_items_desc' => 'Ves a la pestanya Resum per llistar les teves despeses fixes (Lloguer, Assegurances...).',
'bs_rule_search_ph' => '🔍 Cercar una paraula clau (ex: AUCHAN)...',
'error_loading_settings' => 'Error en carregar la configuració',
'bud_err_delete_system_cat' => 'Impossible de suprimir una categoria de sistema essencial.',
// Budget : Suivi
'bud_rem_school' => 'Escola (Resta estimada)',
'bud_rem_fmcg' => 'Compres (Resta estimada)',
'bud_rem_fuel' => 'Benzina (Resta estimada)',
@@ -609,16 +672,16 @@ return [
'bud_import_csv' => 'Importar CSV',
'bud_csv_file' => 'Fitxer CSV (Punt i coma)',
'bud_preview' => 'Previsualitzar',
'bud_validate_import' => 'Validar importació',
'bud_assign_month' => 'Assignar al mes',
'bud_op_date' => 'Data Operació',
'bud_validate_import' => 'Si us plau, comproveu i categoritzeu les línies abans de validar.',
'bud_assign_month' => 'Mes de gestió',
'bud_op_date' => 'Data',
'bud_label' => 'Concepte',
'bud_amount' => 'Import',
'bud_category' => 'Categoria',
'bud_ignore' => 'Ignorar',
'bud_ignore' => 'Ignorar (Crèdit)',
'bud_to_define' => 'Per definir',
'bud_is_charge' => 'Despesa?',
'bud_is_income' => 'Ingrés?',
'bud_is_charge' => 'És un càrrec fix...',
'bud_is_income' => 'És un ingrés...',
'btn_import' => 'Importar',
'bud_no_lines' => 'Cap línia.',
'bud_confirm_delete' => 'Suprimir aquesta línia?',
@@ -637,43 +700,12 @@ return [
'bud_add_title' => 'Afegir',
'bud_edit_title' => 'Modificar la transacció',
'bud_to_define_js' => 'per definir',
'bud_bar_actual' => "Actual",
'bud_bar_theoretical' => "Teòric",
'bud_bar_max_cap' => "Capacitat Màx.",
'bud_edit_title' => 'Modificar la transacció',
'bud_to_define_js' => 'per definir',
'bud_select_beneficiary' => 'Triar un ingrés esperat',
'bud_bar_actual' => 'Actual',
'bud_bar_theoretical' => 'Teòric',
'bud_bar_max_cap' => 'Capacitat Màx.',
'bud_select_beneficiary' => 'Triar un ingrés esperat',
// ONBOARDING BUDGET
'bs_onb_title' => 'Benvinguts! Configurem el teu pressupost:',
'bs_onb_desc' => 'Completa aquests passos perquè l\'aplicació pugui calcular el teu pressupost disponible:',
'bs_onb_cat_link' => 'Categories',
'bs_onb_cat_desc' => 'Crea almenys una categoria de despeses.',
'bs_onb_acc_link' => 'Comptes Bancaris',
'bs_onb_acc_desc' => 'Afegeix almenys un compte corrent o d\'estalvi.',
'bs_onb_sal_link' => 'Sous',
'bs_onb_sal_desc' => 'Introdueix els ingressos de l\'any en curs.',
'bs_onb_items_link' => 'Línies de Pressupost',
'bs_onb_items_desc' => 'Ves a la pestanya Resum per llistar les teves despeses fixes (Lloguer, Assegurances...).',
'bs_rule_search_ph' => '🔍 Cercar una paraula clau (ex: AUCHAN)...',
'error_loading_settings' => 'Error en carregar la configuració',
'settings' => 'Configuració',
// --- MODULE BUDGET : IMPORT CSV ---
'bud_import_csv' => "Importar CSV",
'bud_csv_file' => "Seleccioneu el fitxer del vostre banc (.csv)",
'bud_preview' => "Previsualitzar",
'bud_validate_import' => "Si us plau, comproveu i categoritzeu les línies abans de validar.",
'bud_assign_month' => "Mes de gestió",
'bud_op_date' => "Data",
'bud_amount' => "Import",
'bud_ignore' => "Ignorar (Crèdit)",
'bud_to_define' => "Per definir",
'bud_is_charge' => "És un càrrec fix...",
'bud_is_income' => "És un ingrés...",
// --- BUDGET : RECAP ---
// Budget : Recap
'bud_recap_monthly_title' => 'Resum Mensual',
'bud_recap_btn_add' => 'Afegir una despesa / ingrés',
'bud_planned_amount' => 'Import Previst',
@@ -713,49 +745,16 @@ return [
'bud_label_regularization' => 'Regularització',
'bud_reg_none' => 'Cap',
'bud_recap_confirm_delete' => 'Segur que vols suprimir aquest element?',
'bud_err_delete' => 'Error en suprimir.',
'bud_sav_modal_title_add' => 'Introduir un mes',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_prev_label_name' => 'Nom',
'bud_prev_monthly_target' => 'Import objectiu / Mensualitat (€)',
'bud_prev_transfer_dest' => 'Destinació de la transferència',
'bud_prev_none' => 'Cap destinació (Despesa estàndard)',
// --- BUDGET : EPARGNE ---
// Budget : Epargne
'bud_sav_add_month' => 'Introduir un mes',
'bud_sav_add_one_month' => '+1 Mes',
'bud_sav_no_data' => 'Cap dada per a %s.',
'bud_sav_post_month' => 'Concepte / Mes',
'bud_sav_from_date' => 'Des del %s',
'bud_sav_edit_modal' => 'Modificar amb Modal',
'bud_sav_delete_month' => 'Suprimir tot el mes',
'bud_sav_total_bank' => 'Total Banc',
'bud_sav_extra' => 'Extra',
'bud_sav_modal_title_add' => 'Introduir un mes',
'bud_sav_modal_title_edit' => 'Modificar:',
'bud_sav_month_concerned' => 'Mes afectat',
'bud_sav_total_bank_eur' => 'Total al Banc (€)',
'bud_sav_ventilation' => 'Desglossament',
'bud_sav_adj_help' => 'Utilitzeu l\'ajust (+/-) per recalcular automàticament.',
'bud_sav_add_line' => 'Línia',
'bud_sav_current' => 'Actual',
'bud_sav_adjust' => 'Ajust (+/-)',
'bud_sav_new' => 'Nou',
'bud_sav_sum_mode_title' => 'Activar la suma ràpida',
'bud_sav_selection' => 'Selecció:',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_sav_saving' => 'Desant...',
'bud_err_tech' => 'S\'ha produït un error tècnic.',
'bud_sav_confirm_delete_month' => 'Suprimir TOT el mes de %m per a %o?',
'bud_sav_prompt_duplicate' => "Vols copiar les dades de %s a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
'bud_err_server' => 'Error del servidor: ',
'bud_err_network_dup' => 'Error de xarxa en duplicar.',
'budget_tab_kids' => 'Infants 👶',
'bud_sav_add_one_month' => '+1 Mes',
'bud_sav_add_month' => 'Afegir un mes',
'bud_sav_no_data' => 'Cap dada d\'estalvi per a %s.',
'bud_sav_post_month' => 'Concepte / Mes',
'bud_sav_from_date' => 'Des del %s',
'bud_sav_edit_modal' => 'Editar aquest mes',
'bud_sav_delete_month' => 'Suprimir aquest mes',
'bud_sav_total_bank' => 'TOTAL BANC',
@@ -767,24 +766,20 @@ return [
'bud_sav_ventilation' => 'Desglossament de comptes',
'bud_sav_adj_help' => 'Utilitzeu l\'ajust (+/-) per recalcular automàticament.',
'bud_sav_add_line' => 'Afegir una línia',
'bud_category' => 'Categoria',
'bud_sav_current' => 'Actual',
'bud_sav_adjust' => 'Ajust',
'bud_sav_new' => 'Nou',
'bud_sav_sum_mode_title' => 'Activar/Desactivar la calculadora',
'bud_sav_selection' => 'Selecció:',
'bud_sav_ph_name' => 'Nom del compte',
// JS & Errors
'bud_sav_new_line' => 'Nova línia',
'bud_sav_prompt_new_line' => 'Nom de la nova línia d\'estalvi:',
'bud_sav_confirm_delete_month' => 'Estàs segur que vols suprimir totes les dades de %m per a %o?',
'bud_sav_prompt_duplicate' => "Vols duplicar les dades de %s cap a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
'bud_err_tech' => 'S\'ha produït un error tècnic.',
'bud_err_server' => 'Error del servidor: ',
'bud_err_network_dup' => 'Error de xarxa en duplicar.',
'bud_sav_saving' => 'Desant...',
'bud_err_delete' => 'Error en suprimir.',
// --- BUDGET : PREVISIONNEL ---
// Budget : Previsionnel
'bud_prev_incomes' => 'Ingressos',
'bud_prev_person' => 'Persona',
'bud_prev_salary' => 'Sou',
@@ -818,10 +813,11 @@ return [
'bud_prev_err_no_history' => 'Impossible: no hi ha historial disponible per copiar.',
'bud_prev_confirm_copy' => "Vols copiar les dades de %s a %t?\n\n⚠️ Això sobreescriurà tots els valors ja presents per a %t.",
'bud_prev_confirm_transfers' => "Confirmes que %p ha fet totes les transferències per a %m?\n\nAixò actualitzarà l'Estalvi automàticament.",
'bud_prev_label_name' => 'Nom',
'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_transfer_to' => 'Cap a',
// --- MODULE BUDGET : AVANCES & TRICOUNT ---
// Budget : Tricount / Avances
'bud_adv_title' => 'Avançaments i Tricount Familiar',
'bud_adv_add' => 'Afegir un avançament',
'bud_adv_edit' => 'Modificar l\'avançament',
@@ -831,7 +827,7 @@ return [
'bud_adv_cc_label' => 'al compte comú',
'bud_adv_livret_label' => 'des del seu llibre d\'estalvis',
'bud_adv_cc_balance_title' => 'Equilibri del Compte Comú',
'bud_adv_owed_to' => 'S\'han de pagar **%s €** a **%s**',
'bud_adv_owed_to' => 'S\'han de pagar <strong>%s €</strong> a <strong>%s</strong>',
'bud_adv_balanced' => 'Els comptes estan perfectament equilibrats!',
'bud_adv_saved_badge' => 'Estalvis',
'bud_adv_already_saved' => 'Aquests diners provenen d\'un llibre d\'estalvis',
@@ -839,59 +835,54 @@ return [
'bud_adv_confirm_resolve' => 'Confirmar el reemborsament complet d\'aquest avançament?',
'bud_adv_confirm_delete' => 'Eliminar definitivament aquest avançament?',
// ==========================================
// MÒDUL: REGALS (gift-list)
// ==========================================
// Budget : Provisions & Optimisation
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
'btn_optimize_cashflow' => 'Optimiser mes Économies',
'add_new_provision' => 'Anticiper une grosse dépense à venir',
'provision_label' => 'Libellé de la dépense',
'provision_placeholder_wood' => 'Ex: Commande bois de chauffage, Révision voiture...',
'amount' => 'Montant',
'expected_date' => 'Date prévue',
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
'savings_this_month' => 'Économies globales à placer ce mois-ci',
'calculate' => 'Calculer la stratégie optimale',
'date_past_warning' => 'La date sélectionnée est déjà passée !',
// 1. Títols i capçaleres
// ==========================================
// 8. CADEAUX (GIFTS)
// ==========================================
'gift_page_title' => 'Llista de Regals',
'gift_main_title' => 'Regals de %s', // %s serà substituït per l'any (ex: Regals de 2026)
'settings' => 'Configuració',
// 2. Filtres
'gift_main_title' => 'Regals de %s',
'gift_filter_all_children' => 'Tots els nens',
'gift_filter_all_adults' => 'Tots els adults',
// 3. Contingut i estats buits
'gift_add_gift' => 'Afegir',
'gift_empty_state_no_gifts' => 'Cap regal registrat per a aquesta ocasió.',
'gift_empty_state_no_filter' => 'Cap regal coincideix amb els teus filtres.',
'gift_paid_by' => 'Pagat per %s', // %s serà substituït pel nom del pagador
// 4. Secció Tricount (Balanç i Reemborsaments)
'gift_paid_by' => 'Pagat per %s',
'gift_liquidations' => 'Balanç i Reemborsaments',
'gift_no_debt' => 'Tot està al dia, cap deute!',
'gift_owes' => 'deu',
'gift_view_matrix' => 'Veure la matriu detallada',
'gift_debtor' => 'Deutor',
'gift_creditor' => 'Creditor',
// 5. Modal: Afegir/Modificar Regal
'gift_col_adult' => 'Per compte de',
'gift_modal_payer' => 'Pagat per',
'gift_modal_gift_name' => 'Descripció del regal',
'gift_modal_price' => 'Preu estimat / pagat (€)',
'gift_modal_link' => 'Enllaç del producte (URL)',
'gift_modal_title_add' => 'Afegir per a %s', // JS: %s = nom del nen
'gift_modal_title_edit' => 'Modificar el regal', // JS
'gift_confirm_delete' => 'Vols eliminar realment aquest regal?', // JS
// 6. Modal: Configuració de les Festes
'gift_modal_title_add' => 'Afegir per a %s',
'gift_modal_title_edit' => 'Modificar el regal',
'gift_confirm_delete' => 'Vols eliminar realment aquest regal?',
'gift_settings_title' => 'Configuració de les Festes',
'gift_settings_add_title' => '+ Afegir una ocasió',
'gift_settings_name_placeholder' => 'Nom (ex: Sant Valentí)',
'gift_settings_date_placeholder' => 'MM-DD',
'gift_settings_date_help' => 'La data (MM-DD) és opcional. Deixa en blanc per a una festa mòbil.',
'gift_settings_empty' => 'Cap festa configurada.', // JS
'gift_settings_active' => 'Actiu', // JS
// 7. Botons genèrics
'btn_cancel' => 'Cancel·lar',
'btn_save' => 'Desar',
'btn_add' => 'Afegir',
'loading' => 'Carregant...', //
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings_empty' => 'Cap festa configurada.',
'gift_settings_active' => 'Actiu',
'gift_settings' => 'Configuració',
'gift_tab_occasions' => 'Festes i Ocasions',
'gift_tab_proches' => 'Familiars (Fora de la llar)',
@@ -908,9 +899,8 @@ return [
'gift_settings_edit_proche' => 'Modificar el familiar',
// ==========================================
// GARAGE MANAGER
// 9. GARAGE
// ==========================================
'menu_garage' => 'Garatge',
'garage_page_title' => 'HouseHub - Garatge',
'garage_stat_vehicles' => 'Vehicles',
'garage_stat_maintenances' => 'Manteniments',
@@ -919,7 +909,7 @@ return [
'garage_my_vehicles' => 'Els meus vehicles',
'garage_reminders' => 'Recordatoris i propers manteniments',
'garage_vehicles_title' => 'Els meus vehicles',
'garage_all_parts' => 'Totes les peces',
'garage_all_parts' => 'Toutes les peces',
'garage_parts_count' => 'peces',
'garage_maintenances' => 'Manteniments',
'garage_parts' => 'Peces',
@@ -940,7 +930,6 @@ return [
'garage_photo' => 'Foto',
'garage_notes' => 'Notes',
'garage_maint_type' => 'Tipus de manteniment',
'garage_date' => 'Data',
'garage_km_at' => 'Quilometratge en el moment',
'garage_labor_cost' => 'Cost mà d\'obra (€)',
'garage_description' => 'Descripció',
@@ -959,4 +948,79 @@ return [
'garage_change_photo' => 'Canviar la foto',
'garage_new_photo' => 'Nova foto',
'garage_update_photo' => 'Actualitzar',
// ==========================================
// 10. LISTES & COURSES
// ==========================================
'liste_placeholder_add' => 'Afegir un article...',
'liste_btn_add' => 'Afegir',
'liste_btn_uncheck_all' => 'Tot desmarcar',
'liste_btn_delete_picked' => 'Suprimir els marcats',
'liste_btn_clear_all' => 'Buidar la llista',
'liste_subtitle' => '{pending} pendents · {cart} marcats',
'liste_added' => 'Afegit',
'liste_updated' => 'Modificat',
'liste_deleted' => 'Suprimit',
'liste_already_in' => 'Ja a la llista',
'liste_confirm_clear' => 'Buidar tota la llista?',
'liste_confirm_delete_list' => 'Suprimir aquesta llista i tots els articles?',
'liste_history_title' => 'Recents — clic per afegir',
'liste_new_list' => 'Nova llista',
'liste_list_name_placeholder'=> 'Nom de la llista...',
'liste_create' => 'Crear',
'liste_rename_list' => 'Renombrar',
'liste_new_name' => 'Nou nom:',
'liste_list_deleted' => 'Llista suprimida',
'liste_list_created' => 'Llista creada',
'liste_list_renamed' => 'Llista renombrada',
'liste_edit_placeholder' => 'Modificar l\'article:',
'liste_empty_state' => 'Creeu la primera llista amb el botó + de dalt.',
'liste_settings_title' => 'Llistes — historial',
'liste_settings_intro' => 'Nombre màxim d\'articles memoritzats a l\'historial (comú per a totes).',
'liste_settings_history_max' => 'Mida de l\'historial',
'liste_settings_history_hint'=> 'Entre 1 i 50.',
'groceries_settings_saved' => 'Preferències desades.',
'groceries_settings_error' => 'Impossible de desar:',
'groceries_err_duplicate' => 'Aquest producte ja és a la llista.',
// --- MODULE MEALS ---
'menu_meals' => 'Menús',
'mod_meals_name' => 'Planificació de Menús',
'mod_meals_desc' => 'Planifica els àpats de la setmana per a tota la família.',
'meals_board_title' => 'Planificació de Menús',
'meals_copy_lunch' => 'Copiar tot el dinar',
'meals_copy_dinner' => 'Copiar tot el sopar',
'meal_lunch' => 'Migdia',
'meal_dinner' => 'Sopar',
'btn_apply_all' => 'Tots',
'btn_copy_to' => 'Copiar cap a...',
'modal_copy_title' => 'Copiar cap a...',
'source_individual' => 'Plat individual',
'source_row' => 'Àpat complet',
'week_current' => 'Setmana actual',
'week_next' => 'Setmana vinent',
'meals_no_members' => 'Si us plau, configureu els membres de la vostra família a la configuració.',
'day_full_mon' => 'Dilluns', 'day_full_tue' => 'Dimarts', 'day_full_wed' => 'Dimecres',
'day_full_thu' => 'Dijous', 'day_full_fri' => 'Divendres', 'day_full_sat' => 'Dissabte', 'day_full_sun' => 'Diumenge',
'meal_title' => 'Planificador de Menjars',
'meal_edit_title' => 'Modificar el menjar',
'meal_label_name' => 'Menú previst',
'meal_week' => 'Setmana',
'meal_ph_name' => 'Ex: Lasanya casolana',
'meal_empty_slot' => 'Per definir...',
'meal_current_week' => 'Setmana actual',
'meal_saved' => 'Menjar guardat!',
'meal_btn_import_pdf' => 'Import Menu PDF',
'meal_import_title' => 'Import canteen menu',
'meal_import_child' => 'For which child?',
'meal_select_child' => 'Select child',
'meal_pdf_file' => 'PDF File (Convivio)',
'meal_drop_pdf' => 'Click to add PDF',
'meal_import_success' => 'Menus imported successfully!',
'mod_food_name' => 'Cuina i Compres',
'food_tab_meals' => 'Menú de la setmana',
'food_tab_list' => 'Llista de la compra',
'menu_food' => 'Menjar',
'mod_food_desc' => 'Gestioneu els vostres menús setmanals i les llistes de la compra compartides.',
'cta_food' => 'Cuinar',
];
+339 -257
View File
@@ -1,29 +1,23 @@
<?php
// English Dictionary
// English Dictionary - HouseHub
return [
// ==========================================
// TEST AUTO
// ==========================================
'test_running' => 'Running tests...',
'test_passed' => 'Test passed',
'test_failed' => 'Test failed',
'tests_title' => 'Test Lab',
'tests_run_budget' => 'Test Budget',
'tests_run_all' => 'Run all tests',
'tests_copy_report' => 'Copy report',
'tests_waiting' => 'Waiting to start...',
'tests_report_copied' => 'Report copied to clipboard!',
'tests_live_preview' => 'Live preview',
// ==========================================
// GLOBAL & NAVIGATION
// 1. GLOBAL, NAVIGATION & ACTIONS
// ==========================================
'menu_home' => 'Home',
'menu_calendar' => 'Calendar',
'menu_budget' => 'Budget',
'menu_holidays' => 'Trips',
'menu_gifts' => 'Gifts',
'menu_garage' => 'Garage',
'menu_memo' => 'Notes',
'menu_todo' => 'Todo',
'menu_liste' => 'Lists',
'menu_groceries' => 'Groceries',
'menu_calendar_ios' => 'iOS Calendar',
'menu_printvault' => 'PrintVault',
'menu_planka' => 'Planka',
'nav_home' => 'Home',
'nav_calendar' => 'Calendar',
'nav_budget' => 'Budget',
@@ -31,7 +25,6 @@ return [
'nav_gifts' => 'Gifts',
'aria_menu' => 'Mobile menu',
// Buttons & global actions
'actions' => 'Actions',
'edit' => 'Edit',
'delete' => 'Delete',
@@ -41,28 +34,33 @@ return [
'btn_cancel' => 'Cancel',
'btn_delete' => 'Delete',
'btn_close' => 'Close',
'btn_add' => 'Add',
'btn_back' => '◀ Back',
'btn_edit_bases' => '⚙️ Edit base values',
'btn_edit' => 'Edit',
'error_occured' => 'An error occurred.',
// Generic words & errors
'error' => 'Error.',
'error_occured' => 'An error occurred.',
'error_network' => 'Network error',
'error_generic' => 'An error occurred',
'confirm_delete' => 'Are you sure you want to delete this item?',
'loading' => 'Loading...',
'date' => 'Date',
'settings' => 'Settings',
// Months & Days
'month_01' => 'January', 'month_02' => 'February', 'month_03' => 'March',
'month_04' => 'April', 'month_05' => 'May', 'month_06' => 'June',
'month_07' => 'July', 'month_08' => 'August', 'month_09' => 'September',
'month_10' => 'October', 'month_11' => 'November', 'month_12' => 'December',
'day_mon' => 'Mon', 'day_tue' => 'Tue', 'day_wed' => 'Wed',
'day_thu' => 'Thu', 'day_fri' => 'Fri', 'day_sat' => 'Sat', 'day_sun' => 'Sun',
'month_01' => 'January', 'month_02' => 'February', 'month_03' => 'March', 'month_04' => 'April', 'month_05' => 'May', 'month_06' => 'June',
'month_07' => 'July', 'month_08' => 'August', 'month_09' => 'September', 'month_10' => 'October', 'month_11' => 'November', 'month_12' => 'December',
'month_jan' => 'January', 'month_feb' => 'February', 'month_mar' => 'March', 'month_apr' => 'April', 'month_may' => 'May', 'month_jun' => 'June',
'month_jul' => 'July', 'month_aug' => 'August', 'month_sep' => 'September', 'month_oct' => 'October', 'month_nov' => 'November', 'month_dec' => 'December',
'day_mon' => 'Mon', 'day_tue' => 'Tue', 'day_wed' => 'Wed', 'day_thu' => 'Thu', 'day_fri' => 'Fri', 'day_sat' => 'Sat', 'day_sun' => 'Sun',
// ==========================================
// AUTHENTICATION / LOGIN
// 2. AUTHENTICATION / LOGIN
// ==========================================
'login_title' => 'Login - HouseHub',
'login_header' => 'Login',
'login_subtitle' => 'Please sign in to continue',
'label_username' => 'Username',
'label_password' => 'Password',
'placeholder_username' => 'e.g. john',
@@ -70,18 +68,19 @@ return [
'btn_login_submit' => 'Sign in',
'logout' => 'Logout',
'btn_logout' => 'Logout',
'login_remember_me' => 'Remember me',
'error_missing_fields' => 'All fields are required.',
'error_invalid_credentials' => 'Invalid credentials.',
'login_subtitle' => 'Please sign in to continue',
// ==========================================
// HOME / INDEX
// 3. HOME & DASHBOARD
// ==========================================
'home_title' => 'HouseHub - Home',
'home_welcome' => 'Welcome to HouseHub',
'home_subtitle' => 'Family organisation control centre.',
'home_logged_as' => 'Logged in as',
'home_modules_title' => 'Modules',
'mod_calendar_name' => 'Family Calendar',
'mod_calendar_desc' => 'Manage leave, childcare arrangements and weekly planning.',
'mod_holidays_name' => 'Trips',
@@ -94,52 +93,19 @@ return [
'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Your memos, recipes, instructions and miscellaneous info with Markdown and attachments.',
'menu_memo' => 'Notes',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Manage tasks, shopping lists and family reminders.',
'mod_calendar_ios_name' => 'iOS Calendar',
'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).',
'menu_todo' => 'Todo',
'mod_liste_name' => 'Lists',
'mod_liste_desc' => 'Shared named lists — groceries, DIY, pharmacy… As many lists as needed.',
'mod_groceries_name' => 'Groceries',
'mod_groceries_desc' => 'Shared list: add items and check them off when they are in your cart.',
'menu_groceries' => 'Groceries',
'groceries_placeholder_add' => 'e.g. milk, bread, tomatoes…',
'groceries_btn_add' => 'Add',
'groceries_btn_uncheck_all' => 'Reset cart checks',
'groceries_btn_delete_picked' => 'Remove checked items',
'groceries_subtitle' => '{pending} to pick up · {cart} in cart',
'groceries_added' => 'Added',
'groceries_updated' => 'Updated',
'groceries_deleted' => 'Removed',
'groceries_reset_next' => 'List ready for your next trip',
'groceries_picked_removed' => 'Checked items removed',
'groceries_label_empty' => 'Label cannot be empty',
'groceries_confirm_del_title' => 'Delete?',
'groceries_confirm_del_body' => 'Remove this product from the list?',
'groceries_confirm_uncheck_title' => 'Reset cart checks',
'groceries_confirm_uncheck_body' => 'All “in cart” checkmarks will be cleared. Items stay on the list.',
'groceries_confirm_clear_title' => 'Remove checked items',
'groceries_confirm_clear_body' => 'Every checked item will be deleted from the list.',
'groceries_empty' => 'Nothing yet. Add a product above.',
'groceries_aria_toggle' => 'Mark as in cart or still to pick up',
'groceries_section_pending' => 'To pick up',
'groceries_section_cart' => 'In cart',
'groceries_prompt_edit' => 'Edit product',
'groceries_btn_clear_list' => 'Clear list',
'groceries_confirm_empty_list_title' => 'Clear the entire list?',
'groceries_confirm_empty_list_body' => 'All products will be removed. They stay in history so you can add them again quickly.',
'groceries_list_cleared' => 'List cleared',
'groceries_history_title' => 'History — click to add',
'groceries_history_empty' => 'History fills as you add products (up to the limit set in Settings).',
'groceries_history_meta' => 'Up to {max} products · ',
'groceries_settings_link' => 'Settings',
'groceries_settings_title' => 'Groceries — history',
'groceries_settings_intro' => 'Maximum number of products remembered in history (no duplicates, case-insensitive).',
'groceries_settings_history_max' => 'History size',
'groceries_settings_history_hint' => 'Between 1 and 50. Oldest entries are removed when the limit is exceeded.',
'groceries_settings_saved' => 'Grocery preferences saved.',
'groceries_settings_error' => 'Could not save:',
'groceries_err_duplicate' => 'This product is already on the list.',
'mod_calendar_ios_name' => 'iOS Calendar',
'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).',
'mod_printvault_name'=> 'PrintVault',
'mod_printvault_desc'=> 'Manage your 3D print files — STL, 3MF, GCode with interactive viewer.',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Manage your projects in Kanban mode with Planka.',
'cta_open' => 'Open',
'cta_explore' => 'Explore',
'cta_view_lists' => 'Unwrap',
@@ -149,15 +115,52 @@ return [
'cta_check' => 'Check off',
'cta_cart' => 'Go shopping',
'cta_sync' => 'Sync',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Manage your 3D print files — STL, 3MF, GCode with interactive viewer.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Print',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Manage your projects in Kanban mode with Planka.',
'menu_planka' => 'Planka',
// --- SETTINGS : KIDS ---
// ==========================================
// 4. SETTINGS
// ==========================================
'set_title_page' => '⚙️ Settings',
'set_title_lang' => '🌐 Language',
'set_title_modules' => '🧩 Active Modules',
'set_desc_modules' => 'Choose the modules visible in the navigation.',
'set_title_ios' => '📱 iOS Calendar Integration (CalDAV)',
'set_desc_ios' => 'Configure your iCloud calendar here to sync events.',
'set_label_apple_id' => 'Apple ID (iCloud email)',
'set_label_apple_pwd' => 'Apple app password',
'set_label_apple_url' => 'CalDAV calendar URL',
'set_btn_test_ios' => 'Test connection',
'set_btn_disconnect_ios' => 'Disconnect',
'set_title_bg' => '🖼️ Home Image',
'set_desc_bg' => 'Background photo displayed on the homepage.',
'set_badge_custom_bg' => 'Custom image',
'set_label_new_bg' => 'New image (jpg, png, webp — max 10 MB)',
'set_btn_reset_bg' => '🔄 Reset to default image',
'set_confirm_reset_bg' => 'Revert to default image?',
'set_title_profile' => '👤 My Profile',
'set_label_username' => 'Username',
'set_label_display_name' => 'First name / Display name',
'set_label_profile_color' => 'My profile theme color',
'set_desc_profile_color' => 'This color will be used for your budget grids, calendars, and personal indicators.',
'set_title_pwd' => '🔒 Change Password',
'set_label_cur_pwd' => 'Current password',
'set_label_new_pwd' => 'New password',
'set_label_confirm_pwd' => 'Confirm',
'set_btn_edit' => 'Edit',
'set_title_family' => '🏠 My Family Space',
'set_label_family_name' => 'Space name',
'set_desc_invite' => 'Invitation code — share it to invite someone',
'set_title_copy' => 'Click to copy',
'set_msg_copied' => '✓ Copied!',
'set_btn_new_code' => '🔄 New code',
'set_confirm_new_code' => 'Regenerate code? The old one will be invalidated.',
'set_desc_register_link' => 'Registration link:',
'set_title_members' => 'Members',
'set_badge_admin' => 'Admin',
'set_title_attached' => '🧸 Attached Profiles',
'set_desc_attached' => 'Household members without direct access.',
'set_empty_attached' => 'No attached profiles.',
'set_link_admin' => '→ Administration Panel',
'set_kids_title' => 'Household Kids',
'set_kids_desc' => 'Manage the list of kids to associate them with savings, gifts, etc.',
'set_add_kid_placeholder' => 'Kid\'s first name',
@@ -168,11 +171,67 @@ return [
'set_family_config' => 'Family Structure',
'set_global_params' => 'Global settings',
'set_care_modes' => 'Common childcare arrangements',
'set_add_care_mode' => 'Ajouter un modeAdd a mode',
'btn_save' => 'Save',
'set_add_care_mode' => 'Add a mode',
// Labels Profiles
'label_firstname' => 'First Name',
'placeholder_firstname_ex' => 'e.g., Pol, Carole...',
'label_role' => 'Role',
'role_child' => '👶 Child',
'role_child_icon' => '👶 Child',
'role_helper' => '💼 Helper',
'role_helper_icon' => '💼 Helper',
'role_relative' => '👵 Relative',
'role_relative_icon' => '👵 Relative',
'role_unknown' => 'Unknown',
'label_color' => 'Color',
'change_color' => 'Change color',
'confirm_delete_profile' => 'Permanently delete this profile?',
// Settings Messages
'error_csrf' => 'Invalid session (CSRF). Please reload the page.',
'set_err_min_module' => 'You must keep at least one active module.',
'set_success_modules' => 'Modules successfully updated.',
'set_success_lang' => 'Language updated.',
'set_err_empty_name' => 'First name cannot be empty.',
'set_success_profile' => 'Profile updated successfully.',
'set_err_wrong_pwd' => 'Incorrect current password.',
'set_err_pwd_length' => 'New password must be at least 6 characters long.',
'set_err_pwd_match' => 'Passwords do not match.',
'set_success_pwd' => 'Password successfully changed.',
'set_success_family' => 'Space name updated.',
'set_success_invite' => 'New invitation code generated.',
'set_err_bg_format' => 'Unsupported format (jpg, png, webp).',
'set_success_bg' => 'Home image updated.',
'set_err_upload' => 'Error during upload.',
'set_success_bg_reset' => 'Home image reset.',
'set_err_db_unavailable' => 'Family database unavailable.',
'set_err_ios_missing' => 'Please provide an iCloud ID, app password, and CalDAV URL.',
'set_success_ios' => 'iOS calendar connection saved.',
'set_success_ios_auto' => ' Calendar URL automatically detected.',
'set_err_ios_save' => 'Cannot save iOS connection: ',
'set_err_ios_none' => 'No iOS connection configured.',
'set_success_ios_test' => 'Valid iCloud CalDAV connection (HTTP %s).',
'set_err_ios_test' => 'Connection test failed (HTTP %s). Check your credentials.',
'set_err_ios_test_fail' => 'Connection test unavailable: ',
'set_success_ios_del' => 'iOS connection removed.',
'set_success_profile_add' => 'Profile for %s successfully added.',
'set_err_name_required' => 'First name is required.',
'set_success_profile_del' => 'Profile deleted.',
'set_success_color' => 'Color updated!',
'set_success_color_profile' => 'Profile color updated.',
'set_success_color_update' => 'Color updated!',
'color_updated' => 'Color updated!',
'settings_profile_added' => 'Profile of %s added successfully.',
'settings_err_name_required' => 'First name is required.',
'settings_attached_profiles_title' => 'Attached Profiles',
'settings_attached_profiles_desc' => 'Household members without direct access.',
'settings_no_attached_profiles' => 'No attached profiles.',
'placeholder_apple_id' => 'name@icloud.com',
'placeholder_apple_pwd' => 'xxxx-xxxx-xxxx-xxxx',
// ==========================================
// FAMILY CALENDAR
// 5. FAMILY CALENDAR
// ==========================================
'fc_page_title' => 'HouseHub - Calendar',
'fc_main_header' => 'Family Calendar',
@@ -197,6 +256,7 @@ return [
'fc_weekly_planning' => 'Weekly planning',
'fc_col_month' => 'Month',
'fc_col_week_short' => 'Wk.',
'fc_col_week' => 'Week',
'fc_col_av' => 'Av.',
'fc_col_use' => 'Use',
'fc_legend_title' => 'Legend',
@@ -207,11 +267,12 @@ return [
'fc_unit_days' => 'days',
'fc_menu_carole' => 'Carole\'s Leave',
'fc_clear' => 'Clear',
'fc_menu_kids_leaves' => 'Children\'s Leave',
'fc_menu_kids_leaves' => 'Parent\'s Leave',
'fc_err_save_snap' => 'Error saving adjustment.',
'fc_err_fetch_gov' => 'Error connecting to the government API.',
'fc_err_no_data_gov' => 'No data found on the government API for this year.',
'fc_err_action' => 'Error during action: ',
'fc_err_select_leave' => 'Please select a leave type.',
'fc_today_short' => 'Today',
'fc_today_title' => 'Today',
'fc_tab_foyer' => '🏡 Household',
@@ -219,8 +280,9 @@ return [
'fc_select_member' => 'Select a family member:',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Child',
'fc_role_helper' => 'Helper',
'fc_role_unknown' => 'Unknown',
'fc_care_modes_title' => 'Associated Care Modes',
'fc_care_modes_desc' => 'Check the care modes that apply to this child.',
'fc_no_care_mode' => 'No care mode configured.',
'fc_matrix_title' => '👨‍👩‍👦 Leave Rights Matrix',
'fc_matrix_desc' => 'Declare your counter codes and renewal dates.',
@@ -233,77 +295,33 @@ return [
'btn_save_rights' => '💾 Save',
'fc_modal_settings_title' => 'Calendar Settings',
'fc_zone_label' => 'School Zone (Holidays)',
'fc_zone_desc' => 'Determines the automatic school holiday calendar.',
'fc_care_modes_label' => 'Household Care Modes',
'fc_care_modes_desc' => 'Manage the global list of possible care modes for the family.',
'fc_add_mode_placeholder' => 'E.g., Nanny, Center, Grandparents...',
'fc_settings_updated' => 'Settings updated successfully!',
'fc_first_day' => 'First day of the week',
// --- CALENDAR: SETTINGS (MEMBERS & DISPLAY) ---
'fc_tab_display' => '🖥️ Display',
'fc_display_title' => 'Display Settings',
'fc_display_desc' => 'Customize the default appearance and behavior of your calendar.',
'fc_default_view' => 'Default View',
'fc_view_month' => '1 month',
'fc_view_2months' => '2 months',
'fc_view_3months' => '3 months',
'fc_working_hours' => 'Default Care Hours',
'fc_working_hours_desc' => 'Example: 08:30-18:00',
'fc_role_unknown' => 'Unknown',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Child',
'fc_role_helper' => 'Helper',
'fc_error' => 'Error: ',
'fc_care_modes_title' => 'Default Care Modes',
'fc_care_modes_desc' => 'Which care modes apply to this child?',
'fc_no_care_mode' => 'No care mode configured for this household.',
'fc_child_saved' => 'Saved!',
'fc_matrix_title' => 'Leave Matrix',
'fc_matrix_desc' => 'Define quotas and renewal dates.',
'fc_matrix_empty' => 'No counters defined.',
"fc_matrix_saved" => "Matrix saved!",
'fc_leave_obsolete' => 'Obsolete',
'fc_leave_code_placeholder' => 'Code...',
'fc_leave_method_fixed' => 'Fixed',
'fc_leave_method_accumulated' => 'Accrued',
'fc_date_format_placeholder' => 'DD/MM',
'fc_date_format_title' => 'DD/MM Format',
'fc_table_code' => 'Code',
'fc_table_method' => 'Method',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renewal',
'btn_save_rights' => 'Save',
// ==========================================
// CALENDAR SETTINGS MODAL
// ==========================================
'fc_modal_settings_title' => 'Calendar Settings',
'fc_tab_foyer' => 'Household',
'fc_tab_members' => 'Members & Rights',
// Household: School Zone
'fc_zone_label' => 'School zone',
'fc_zone_a' => 'Zone A',
'fc_zone_b' => 'Zone B',
'fc_zone_c' => 'Zone C',
'fc_zone_other' => 'Other / Outside France',
'fc_zone_desc' => 'Used to accurately display school holidays.',
// Household: Care Modes
'fc_care_modes_label' => 'Childcare options',
'fc_add_mode_placeholder' => 'Ex: Nanny, Daycare...',
'fc_care_modes_desc' => 'Add the different childcare options for your children.',
'fc_zone_desc' => 'Determines the automatic school holiday calendar.',
'fc_care_modes_label' => 'Household Care Modes',
'fc_care_modes_desc' => 'Manage the global list of possible care modes for the family.',
'fc_add_mode_placeholder' => 'E.g., Nanny, Center, Grandparents...',
'fc_empty_kids_msg' => 'No children are configured in your household yet.',
'fc_link_settings' => 'Go to general settings',
// Household: Leave Catalog
'fc_settings_updated' => 'Settings updated successfully!',
'fc_first_day' => 'First day of the week',
'fc_helper_recap' => 'Helper Summary',
'fc_no_helper_leaves' => 'No leaves taken this month.',
'fc_col_date' => 'Date',
'fc_col_type' => 'Type',
'fc_col_duration' => 'Duration',
'fc_full_year' => 'Full year',
'fc_custom_period' => 'Custom period...',
'fc_worked_days' => 'Worked (Est.)',
'fc_tab_display' => '🖥️ Display',
'fc_display_title' => 'Display Settings',
'fc_display_desc' => 'Customize the default appearance and behavior of your calendar.',
'fc_default_view' => 'Default View',
'fc_working_hours' => 'Default Care Hours',
'fc_working_hours_desc' => 'Example: 08:30-18:00',
'fc_error' => 'Error: ',
'fc_leave_catalog_title' => 'Leave Catalog',
'fc_leave_catalog_desc' => 'Define the possible leave types for the whole family.',
'fc_add_leave_type' => 'Add a leave type',
@@ -313,24 +331,34 @@ return [
'fc_leave_label_ph' => 'Ex: PTO, Sick leave...',
'fc_quota_default' => 'Default quota',
'fc_reset_month' => 'Reset month',
'fc_leave_obsolete' => 'Obsolete',
'fc_leave_code_placeholder' => 'Code...',
'fc_leave_method_fixed' => 'Fixed',
'fc_leave_method_accumulated'=> 'Accrued',
'fc_date_format_placeholder'=> 'DD/MM',
'fc_date_format_title' => 'DD/MM Format',
'fc_table_code' => 'Code',
'fc_table_method' => 'Method',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renewal',
'fc_label_carry_over' => 'Incl. carried over days (Expiring) ⚠️',
'fc_carry_over_note' => 'Portion of the overall balance that will expire on the deadline.',
'fc_carry_max' => 'Max Carry Over',
'fc_carry_deadline' => 'Expiration Month',
'fc_deadline_none' => 'None',
'fc_allowance_help' => 'Fixed: Annual quota. Accrued: Added per month.',
'fc_carry_max_short' => 'Max carry-over',
'fc_edit_leave' => '✏️ Edit:',
'fc_not_modifiable' => 'Not modifiable',
'fc_add_leave' => '+ Add',
'fc_err_code_label' => 'Code and Label are required.',
'fc_catalog_updated' => 'Catalog updated!',
'fc_balance_corrected' => 'Balance corrected!',
'fc_no_leave_configured' => 'No leave configured.',
'fc_confirm_del_catalog' => 'Permanently delete leave %s from the catalog? This will also remove it from members using it.',
'fc_confirm_del_member_leave'=> 'Do you really want to remove this leave for this member?',
// Members
'fc_select_member' => 'Select a member:',
// Months
'month_jan' => 'January',
'month_feb' => 'February',
'month_mar' => 'March',
'month_apr' => 'April',
'month_may' => 'May',
'month_jun' => 'June',
'month_jul' => 'July',
'month_aug' => 'August',
'month_sep' => 'September',
'month_oct' => 'October',
'month_nov' => 'November',
'month_dec' => 'December',
// Calendar Legends
'leg_presence' => 'Pep presence',
'leg_school_holidays' => 'School holidays',
'leg_public_holiday' => 'Public holiday',
@@ -338,11 +366,10 @@ return [
'leg_extra_off' => 'Extra off',
'leg_centre' => 'Centre',
'leg_avis' => 'Notice',
'leg_pep_sick' => 'Pep sick',
'fc_menu_sick' => 'Sick',
'care_nounou' => 'Nanny',
'care_centre' => 'Daycare',
'care_avis' => 'Grandparents',
'vac_toussaint' => 'All Saints\' holidays',
'vac_noel' => 'Christmas holidays',
'vac_hiver' => 'Winter holidays',
@@ -351,12 +378,11 @@ return [
'vac_ete' => 'Summer holidays',
'btn_off' => 'Off',
'btn_extra' => 'Extra',
'fc_alert_burn_days' => '⚠️ Risk of losing: %s d to take before %s',
'fc_alert_burn_jra' => '⚠️ JRA: Only 2 days can be carried over after February!',
'fc_alert_burn_days' => '⚠️ Risk of losing: %s d to take before end of cycle',
'fc_alert_burn_jra' => '⚠️ Alert: Carried over days expire at the end of this month!',
// ==========================================
// HOLIDAYS (TRIPS)
// 6. HOLIDAYS (TRIPS)
// ==========================================
'hdl_title_list' => 'HouseHub - My Trips',
'hdl_title_detail' => 'HouseHub - Trip details',
@@ -431,6 +457,8 @@ return [
'hdl_to_place' => 'To place',
'hdl_from' => 'From',
'hdl_to' => 'To',
// Weather & Holidays specific
'weather_forecast' => 'Weather forecast',
'weather_not_available' => 'Weather unavailable',
'temp_max' => 'Max temp.',
@@ -498,7 +526,7 @@ return [
'to_place' => '📥 To place',
// ==========================================
// BUDGET (GLOBAL & CATEGORIES)
// 7. BUDGET (GLOBAL, SUIVI, PREV, EPARGNE, PROVISIONS)
// ==========================================
'budget_page_title' => 'HouseHub - Budget',
'budget_main_header' => 'Budget Management',
@@ -506,9 +534,16 @@ return [
'budget_tab_prev' => 'Budget 2026',
'budget_tab_savings' => 'Savings',
'budget_tab_recap' => 'Summary',
'budget_tab_provisions' => 'Provisions',
'budget_tab_kids' => 'Kids',
'budget_err_file_not_found' => 'Oops! File not found.',
'budget_err_file_detail' => 'Check that the file views/%s.php exists.',
'budget_err_not_authorized' => 'Page not authorised.',
'bud_err_tech' => 'A technical error occurred.',
'bud_err_server' => 'Server error: ',
'bud_err_delete' => 'Error while deleting.',
// Categories (Budget)
'cat_income' => 'Income',
'cat_fmcg' => 'Groceries (FMCG)',
'cat_fuel' => 'Fuel',
@@ -516,12 +551,14 @@ return [
'cat_fixed' => 'Fixed costs',
'cat_others' => 'Other / Unexpected',
'cat_savings' => 'Savings',
'budget_tab_kids' => 'Kids',
// Settings (Budget)
'budget_settings_title' => 'Budget Settings',
'bs_tab_accounts' => '💳 Accounts',
'bs_tab_categories' => '🏷️ Categories',
'bs_tab_rules' => '🤖 Import Rules',
'bs_tab_salaries' => '💼 Salaries',
'bs_tab_csv' => '📥 CSV Import',
'bs_empty_accounts' => 'No accounts configured.',
'bs_type_checking' => 'Checking',
'bs_type_savings' => 'Savings',
@@ -551,7 +588,23 @@ return [
'bs_currency_label' => 'Household Currency',
'bs_currency_desc' => 'Main currency used for displaying your accounts and budgets.',
'bs_currency_updated' => 'Currency updated successfully!',
// ONGLET CSV MAPPING
'bud_settings_dyn_title' => 'Dynamic Estimates (Children)',
'bud_dyn_nanny_fixed' => 'Nanny fixed salary (€)',
'bud_dyn_nanny_daily' => 'Daily maintenance fee (€)',
'bud_dyn_nanny_aid' => 'Government aid (€)',
'bud_dyn_nanny_cesu_avg' => 'Average CESU paid (€)',
'bud_dyn_school_meal' => 'School meal price (€)',
'bud_dyn_school_aftercare' => 'After-school care price (€)',
'bud_dyn_school_fullday' => 'Full day care (Wednesdays/Holidays) (€)',
'bud_dyn_pattern_title' => 'Standard school week',
'bud_audit_month' => 'Analyzed month: %02d/%d',
'bud_audit_nanny_abs' => '%s absences deducted : %dd',
'bud_audit_child' => '👦 %s :',
'bud_audit_school_fee' => 'Canteen (%dd) + Aftercare (%dd) + Center (%dd) = %.2f€',
'bud_audit_sick' => 'Sick days deducted : %dd',
'bud_audit_nanny_full' => '💼 %s : Fixed (%.2f€) + Fees (%dd x %.2f€ = %.2f€) - Aid (%.2f€) - CESU (%.2f€) = %.2f€',
// CSV Mapping (Budget)
'bs_csv_title' => 'CSV Import Format',
'bs_csv_desc' => 'Configure the columns that match your bank\'s export file.',
'bs_csv_delimiter' => 'Delimiter',
@@ -572,7 +625,6 @@ return [
'bs_csv_col_ref' => 'Col #: Unique Reference (Optional)',
'btn_save_format' => 'Save format',
'bs_csv_saved' => 'CSV format saved',
'bs_tab_csv' => '📥 CSV Import',
'bs_csv_settings_title' => 'Format Configuration',
'bs_csv_drop_title' => 'Drop your CSV file here',
'bs_csv_drop_desc' => 'or click to browse your files',
@@ -582,7 +634,7 @@ return [
'bs_csv_empty_col' => 'Empty',
'bs_csv_no_data' => 'No transaction row found in this file.',
// ONBOARDING BUDGET
// Onboarding Budget
'bs_onb_title' => 'Welcome! Let\'s set up your budget:',
'bs_onb_desc' => 'Complete these steps so the app can calculate your remaining budget:',
'bs_onb_cat_link' => 'Categories',
@@ -595,9 +647,9 @@ return [
'bs_onb_items_desc' => 'Go to the Summary tab to list your fixed expenses (Rent, Insurance...).',
'bs_rule_search_ph' => '🔍 Search for a keyword (e.g. WALMART)...',
'error_loading_settings' => 'Error loading settings',
'settings' => 'Settings',
'bud_err_delete_system_cat' => 'Cannot delete a core system category.',
// --- BUDGET: TRACKING ---
// Budget : Tracking
'bud_rem_school' => 'School (estimated remaining)',
'bud_rem_fmcg' => 'Groceries (estimated remaining)',
'bud_rem_fuel' => 'Fuel (estimated remaining)',
@@ -653,8 +705,9 @@ return [
'bud_bar_actual' => 'Actual',
'bud_bar_theoretical' => 'Theoretical',
'bud_bar_max_cap' => 'Max capacity',
'bud_select_beneficiary' => 'Choose an expected income',
// --- BUDGET: RECAP ---
// Budget : Recap
'bud_recap_monthly_title' => 'Monthly summary',
'bud_recap_btn_add' => 'Add a cost / income',
'bud_planned_amount' => 'Planned amount',
@@ -694,46 +747,16 @@ return [
'bud_label_regularization' => 'Regularisation',
'bud_reg_none' => 'None',
'bud_recap_confirm_delete' => 'Are you sure you want to delete this item?',
'bud_err_delete' => 'Error while deleting.',
'bud_prev_monthly_target' => 'Target amount / Monthly payment (€)',
'bud_prev_transfer_dest' => 'Transfer destination',
'bud_prev_none' => 'No destination (Standard expense)',
// --- BUDGET: SAVINGS ---
// Budget : Savings
'bud_sav_add_month' => 'Enter a month',
'bud_sav_add_one_month' => '+1 Month',
'bud_sav_no_data' => 'No data for %s.',
'bud_sav_post_month' => 'Category / Month',
'bud_sav_from_date' => 'From %s',
'bud_sav_edit_modal' => 'Edit with modal',
'bud_sav_delete_month' => 'Delete entire month',
'bud_sav_total_bank' => 'Total bank',
'bud_sav_extra' => 'Extra',
'bud_sav_modal_title_add' => 'Enter a month',
'bud_sav_modal_title_edit' => 'Edit:',
'bud_sav_month_concerned' => 'Month concerned',
'bud_sav_total_bank_eur' => 'Total in bank (€)',
'bud_sav_ventilation' => 'Breakdown',
'bud_sav_adj_help' => 'Use the adjustment (+/-) to recalculate automatically.',
'bud_sav_add_line' => 'Line',
'bud_sav_current' => 'Current',
'bud_sav_adjust' => 'Adj. (+/-)',
'bud_sav_new' => 'New',
'bud_sav_sum_mode_title' => 'Enable quick addition',
'bud_sav_selection' => 'Selection:',
'bud_sav_ph_name' => 'Name (e.g. Holidays)',
'bud_sav_saving' => 'Saving...',
'bud_err_tech' => 'A technical error occurred.',
'bud_sav_confirm_delete_month' => 'Delete ALL of month %m for %o?',
'bud_sav_prompt_duplicate' => "Do you want to copy data from %s to %t1?\n\nEnter the new TOTAL bank balance (€) for %t2:",
'bud_err_server' => 'Server error: ',
'bud_err_network_dup' => 'Network error during duplication.',
'budget_tab_kids' => 'Kids 👶',
'bud_sav_add_one_month' => '+1 Month',
'bud_sav_add_month' => 'Add a month',
'bud_sav_no_data' => 'No savings data for %s.',
'bud_sav_post_month' => 'Item / Month',
'bud_sav_from_date' => 'From %s',
'bud_sav_edit_modal' => 'Edit this month',
'bud_sav_delete_month' => 'Delete this month',
'bud_sav_total_bank' => 'TOTAL BANK',
@@ -745,24 +768,20 @@ return [
'bud_sav_ventilation' => 'Accounts breakdown',
'bud_sav_adj_help' => 'Enter adjustments (+/-) to auto-calculate.',
'bud_sav_add_line' => 'Add line',
'bud_category' => 'Category',
'bud_sav_current' => 'Current',
'bud_sav_adjust' => 'Adjustment',
'bud_sav_new' => 'New',
'bud_sav_sum_mode_title' => 'Toggle calculator',
'bud_sav_selection' => 'Selection:',
'bud_sav_ph_name' => 'Account name',
// JS & Errors
'bud_sav_new_line' => 'New line',
'bud_sav_prompt_new_line' => 'Name of the new savings line:',
'bud_sav_confirm_delete_month' => 'Are you sure you want to delete all data of %m for %o?',
'bud_sav_prompt_duplicate' => "Do you want to duplicate data from %s to %t1?\n\nEnter the new BANK TOTAL (€) for %t2:",
'bud_err_tech' => 'A technical error occurred.',
'bud_err_server' => 'Server error: ',
'bud_err_network_dup' => 'Network error during duplication.',
'bud_sav_saving' => 'Saving...',
'bud_err_delete' => 'Error during deletion.',
// --- BUDGET: FORECAST ---
// Budget : Forecast
'bud_prev_incomes' => 'Income',
'bud_prev_person' => 'Person',
'bud_prev_salary' => 'Salary',
@@ -797,6 +816,10 @@ return [
'bud_prev_confirm_copy' => "Do you want to copy data from %s to %t?\n\n⚠️ This will overwrite all values already present for %t.",
'bud_prev_confirm_transfers' => "Confirm that %p has made all their transfers for %m?\n\nThis will automatically update Savings.",
'bud_prev_label_name' => 'Name',
'bud_prev_target_ph' => 'E.g., 150',
'bud_prev_transfer_to' => 'To',
// Budget : Tricount / Advances
'bud_adv_title' => 'Advances & Family Tricount',
'bud_adv_add' => 'Add an Advance',
'bud_adv_edit' => 'Edit Advance',
@@ -813,70 +836,55 @@ return [
'bud_adv_ph_desc' => 'e.g., Groceries, Electricity bill...',
'bud_adv_confirm_resolve' => 'Confirm the complete reimbursement of this advance?',
'bud_adv_confirm_delete' => 'Permanently delete this advance?',
'date' => 'Date',
'bud_label_name' => 'Description',
'bud_amount' => 'Amount',
'actions' => 'Actions',
'edit' => 'Edit',
'delete' => 'Delete',
'btn_cancel' => 'Cancel',
'btn_save' => 'Save',
'bud_prev_target_ph' => 'E.g., 150',
'bud_prev_transfer_to' => 'To',
// Budget : Provisions & Optimization
'budget_provisions_title' => 'Provisions & Cashflow Optimization',
'btn_optimize_cashflow' => 'Optimize My Savings',
'add_new_provision' => 'Anticipate an upcoming major expense',
'provision_label' => 'Expense description',
'provision_placeholder_wood' => 'E.g., Firewood order, Car service...',
'amount' => 'Amount',
'expected_date' => 'Expected date',
'upcoming_provisions_list' => 'Scheduled expenses',
'optimization_assistant_title' => 'Fortnightly Interest Optimization Assistant',
'success_add_provision' => 'Forecasted expense added successfully!',
'no_provisions' => 'No forecasted expenses registered at the moment.',
'savings_this_month' => 'Total savings to allocate this month',
'calculate' => 'Calculate optimal strategy',
'date_past_warning' => 'The selected date has already passed!',
// ==========================================
// MODULE: GIFTS (gift-list)
// 8. GIFTS
// ==========================================
// 1. Titles and headers
'gift_page_title' => 'Gift List',
'gift_main_title' => 'Gifts of %s', // %s will be replaced by the year (e.g., Gifts of 2026)
'settings' => 'Settings',
// 2. Filters
'gift_main_title' => 'Gifts of %s',
'gift_filter_all_children' => 'All children',
'gift_filter_all_adults' => 'All adults',
// 3. Content and empty states
'gift_add_gift' => 'Add',
'gift_empty_state_no_gifts' => 'No gifts recorded for this occasion.',
'gift_empty_state_no_filter' => 'No gifts match your filters.',
'gift_paid_by' => 'Paid by %s', // %s will be replaced by the payer's name
// 4. Tricount Section (Balance & Refunds)
'gift_paid_by' => 'Paid by %s',
'gift_liquidations' => 'Balance & Refunds',
'gift_no_debt' => 'Everything is settled, no debts!',
'gift_owes' => 'owes',
'gift_view_matrix' => 'View detailed matrix',
'gift_debtor' => 'Debtor',
'gift_creditor' => 'Creditor',
// 5. Modal: Add/Edit Gift
'gift_col_adult' => 'On behalf of',
'gift_modal_payer' => 'Paid by',
'gift_modal_gift_name' => 'Gift description',
'gift_modal_price' => 'Estimated / paid price (€)',
'gift_modal_link' => 'Product link (URL)',
'gift_modal_title_add' => 'Add for %s', // JS: %s = child's name
'gift_modal_title_edit' => 'Edit gift', // JS
'gift_confirm_delete' => 'Are you sure you want to delete this gift?', // JS
// 6. Modal: Occasions Settings
'gift_modal_title_add' => 'Add for %s',
'gift_modal_title_edit' => 'Edit gift',
'gift_confirm_delete' => 'Are you sure you want to delete this gift?',
'gift_settings_title' => 'Occasions Settings',
'gift_settings_add_title' => '+ Add an occasion',
'gift_settings_name_placeholder' => 'Name (e.g. Valentine\'s Day)',
'gift_settings_date_placeholder' => 'MM-DD',
'gift_settings_date_help' => 'Date (MM-DD) is optional. Leave empty for a floating date.',
'gift_settings_empty' => 'No occasion configured.', // JS
'gift_settings_active' => 'Active', // JS
// 7. Generic buttons
'btn_cancel' => 'Cancel',
'btn_save' => 'Save',
'btn_add' => 'Add',
'loading' => 'Loading...',
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings_empty' => 'No occasion configured.',
'gift_settings_active' => 'Active',
'gift_settings' => 'Settings',
'gift_tab_occasions' => 'Occasions & Events',
'gift_tab_proches' => 'Relatives (Outside household)',
@@ -893,9 +901,8 @@ return [
'gift_settings_edit_proche' => 'Edit relative',
// ==========================================
// GARAGE MANAGER
// 9. GARAGE
// ==========================================
'menu_garage' => 'Garage',
'garage_page_title' => 'HouseHub - Garage',
'garage_stat_vehicles' => 'Vehicles',
'garage_stat_maintenances' => 'Services',
@@ -944,4 +951,79 @@ return [
'garage_change_photo' => 'Change photo',
'garage_new_photo' => 'New photo',
'garage_update_photo' => 'Update',
// ==========================================
// 10. LISTS & GROCERIES
// ==========================================
'liste_placeholder_add' => 'Add an item...',
'liste_btn_add' => 'Add',
'liste_btn_uncheck_all' => 'Uncheck all',
'liste_btn_delete_picked' => 'Delete checked items',
'liste_btn_clear_all' => 'Clear list',
'liste_subtitle' => '{pending} to do · {cart} checked',
'liste_added' => 'Added',
'liste_updated' => 'Updated',
'liste_deleted' => 'Deleted',
'liste_already_in' => 'Already in the list',
'liste_confirm_clear' => 'Clear the entire list?',
'liste_confirm_delete_list' => 'Delete this list and all its items?',
'liste_history_title' => 'Recently used — click to add',
'liste_new_list' => 'New list',
'liste_list_name_placeholder'=> 'List name...',
'liste_create' => 'Create',
'liste_rename_list' => 'Rename',
'liste_new_name' => 'New name:',
'liste_list_deleted' => 'List deleted',
'liste_list_created' => 'List created',
'liste_list_renamed' => 'List renamed',
'liste_edit_placeholder' => 'Edit item:',
'liste_empty_state' => 'Create your first list using the + button above.',
'liste_settings_title' => 'Lists — history',
'liste_settings_intro' => 'Maximum number of items remembered in history (shared across lists).',
'liste_settings_history_max' => 'History size',
'liste_settings_history_hint'=> 'Between 1 and 50.',
'groceries_settings_saved' => 'Preferences saved.',
'groceries_settings_error' => 'Could not save:',
'groceries_err_duplicate' => 'This product is already on the list.',
// --- MODULE MEALS ---
'menu_meals' => 'Meals',
'mod_meals_name' => 'Meal Plan',
'mod_meals_desc' => 'Plan weekly meals for the whole family.',
'meals_board_title' => 'Meal Planner',
'meals_copy_lunch' => 'Copy entire lunch',
'meals_copy_dinner' => 'Copy entire dinner',
'meal_lunch' => 'Lunch',
'meal_dinner' => 'Dinner',
'btn_apply_all' => 'All',
'btn_copy_to' => 'Copy to...',
'modal_copy_title' => 'Copy to...',
'source_individual' => 'Individual meal',
'source_row' => 'Full meal',
'week_current' => 'Current week',
'week_next' => 'Next week',
'meals_no_members' => 'Please configure family members in the settings.',
'day_full_mon' => 'Monday', 'day_full_tue' => 'Tuesday', 'day_full_wed' => 'Wednesday',
'day_full_thu' => 'Thursday', 'day_full_fri' => 'Friday', 'day_full_sat' => 'Saturday', 'day_full_sun' => 'Sunday',
'meal_title' => 'Meal Planner',
'meal_edit_title' => 'Edit meal',
'meal_label_name' => 'Planned menu',
'meal_ph_name' => 'E.g. Homemade lasagna',
'meal_week' => 'Week',
'meal_empty_slot' => 'To be defined...',
'meal_current_week' => 'Current week',
'meal_saved' => 'Meal saved!',
'meal_btn_import_pdf' => 'Import Menu PDF',
'meal_import_title' => 'Import canteen menu',
'meal_import_child' => 'For which child?',
'meal_select_child' => 'Select child',
'meal_pdf_file' => 'PDF File (Convivio)',
'meal_drop_pdf' => 'Click to add PDF',
'meal_import_success' => 'Menus imported successfully!',
'mod_food_name' => 'Kitchen & Groceries',
'food_tab_meals' => 'Weekly Menu',
'food_tab_list' => 'Shopping List',
'menu_food' => 'Food',
'mod_food_desc' => 'Manage your weekly menus and shared shopping lists.',
'cta_food' => 'Cook up',
];
+357 -293
View File
@@ -1,30 +1,23 @@
<?php
// Dictionnaire Français
// Dictionnaire Français - HouseHub (Optimisé & Dédupliqué)
return [
// ==========================================
// TEST AUTO
// ==========================================
'test_running' => 'Tests en cours...',
'test_passed' => 'Test réussi',
'test_failed' => 'Test échoué',
'tests_title' => 'Laboratoire de Tests',
'tests_run_budget' => 'Tester le Budget',
'tests_run_all' => 'Lancer tous les tests',
'tests_copy_report' => 'Copier le rapport',
'tests_waiting' => 'En attente du lancement...',
'tests_report_copied' => 'Rapport copié dans le presse-papier !',
'tests_live_preview' => 'Aperçu en direct',
// ==========================================
// GLOBAL & NAVIGATION
// 1. GLOBAL, NAVIGATION & ACTIONS
// ==========================================
'menu_home' => 'Accueil',
'menu_calendar' => 'Calendrier',
'menu_budget' => 'Budget',
'menu_holidays' => 'Voyages',
'menu_gifts' => 'Cadeaux',
'menu_garage' => 'Garage',
'menu_memo' => 'Notes',
'menu_todo' => 'Todo',
'menu_liste' => 'Listes',
'menu_groceries' => 'Courses',
'menu_calendar_ios' => 'Calendrier iOS',
'menu_printvault' => 'PrintVault',
'menu_planka' => 'Planka',
'nav_home' => 'Accueil',
'nav_calendar' => 'Calendrier',
'nav_budget' => 'Budget',
@@ -32,8 +25,6 @@ return [
'nav_gifts' => 'Cadeaux',
'aria_menu' => 'Menu mobile',
// Boutons et actions globales
'actions' => 'Actions',
'edit' => 'Modifier',
'delete' => 'Supprimer',
@@ -43,28 +34,33 @@ return [
'btn_cancel' => 'Annuler',
'btn_delete' => 'Supprimer',
'btn_close' => 'Fermer',
'btn_add' => 'Ajouter',
'btn_back' => '◀ Retour',
'btn_edit_bases' => '⚙️ Modifier les bases',
'btn_edit' => 'Modifier',
'error_occured' => 'Une erreur est survenue.',
// Mots génériques & Erreurs
'error' => 'Erreur.',
'error_occured' => 'Une erreur est survenue.',
'error_network' => 'Erreur réseau',
'error_generic' => 'Une erreur est survenue',
'confirm_delete' => 'Es-tu sûr de vouloir supprimer cet élément ?',
'loading' => 'Chargement...',
'date' => 'Date',
'settings' => 'Paramètres',
// Mois & Jours
'month_01' => 'Janvier', 'month_02' => 'Février', 'month_03' => 'Mars',
'month_04' => 'Avril', 'month_05' => 'Mai', 'month_06' => 'Juin',
'month_07' => 'Juillet', 'month_08' => 'Août', 'month_09' => 'Septembre',
'month_10' => 'Octobre', 'month_11' => 'Novembre','month_12' => 'Décembre',
'day_mon' => 'Lun', 'day_tue' => 'Mar', 'day_wed' => 'Mer',
'day_thu' => 'Jeu', 'day_fri' => 'Ven', 'day_sat' => 'Sam', 'day_sun' => 'Dim',
'month_01' => 'Janvier', 'month_02' => 'Février', 'month_03' => 'Mars', 'month_04' => 'Avril', 'month_05' => 'Mai', 'month_06' => 'Juin',
'month_07' => 'Juillet', 'month_08' => 'Août', 'month_09' => 'Septembre', 'month_10' => 'Octobre', 'month_11' => 'Novembre', 'month_12' => 'Décembre',
'month_jan' => 'Janvier', 'month_feb' => 'Février', 'month_mar' => 'Mars', 'month_apr' => 'Avril', 'month_may' => 'Mai', 'month_jun' => 'Juin',
'month_jul' => 'Juillet', 'month_aug' => 'Août', 'month_sep' => 'Septembre', 'month_oct' => 'Octobre', 'month_nov' => 'Novembre', 'month_dec' => 'Décembre',
'day_mon' => 'Lun', 'day_tue' => 'Mar', 'day_wed' => 'Mer', 'day_thu' => 'Jeu', 'day_fri' => 'Ven', 'day_sat' => 'Sam', 'day_sun' => 'Dim',
// ==========================================
// AUTHENTIFICATION / LOGIN
// 2. AUTHENTIFICATION / LOGIN
// ==========================================
'login_title' => 'Connexion - HouseHub',
'login_header' => 'Connexion',
'login_subtitle' => 'Veuillez vous identifier pour continuer',
'label_username' => 'Identifiant',
'label_password' => 'Mot de passe',
'placeholder_username' => 'ex: pacha',
@@ -72,18 +68,19 @@ return [
'btn_login_submit' => 'Se connecter',
'logout' => 'Déconnexion',
'btn_logout' => 'Déconnexion',
'login_remember_me' => 'Se souvenir de moi',
'error_missing_fields' => 'Champs obligatoires.',
'error_invalid_credentials' => 'Identifiants incorrects.',
'login_subtitle' => 'Veuillez vous identifier pour continuer',
// ==========================================
// ACCUEIL / INDEX
// 3. ACCUEIL & DASHBOARD
// ==========================================
'home_title' => 'HouseHub - Accueil',
'home_welcome' => 'Bienvenue sur HouseHub',
'home_subtitle' => "Centre de contrôle de l'organisation familiale.",
'home_logged_as' => 'Connecté en tant que',
'home_modules_title' => 'Modules',
'mod_calendar_name' => 'Calendrier Familial',
'mod_calendar_desc' => "Gérez les congés, les modes de garde et l'organisation hebdomadaire.",
'mod_holidays_name' => 'Vacances',
@@ -96,49 +93,19 @@ return [
'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Vos mémos, recettes, instructions et infos diverses avec Markdown et pièces jointes.',
'menu_memo' => 'Notes',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Gérez vos tâches, listes de courses et rappels en famille.',
'mod_calendar_ios_name' => 'Calendrier iOS',
'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).',
'menu_todo' => 'Todo',
'mod_groceries_name' => 'Courses',
'mod_groceries_desc' => 'Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.',
'menu_groceries' => 'Courses',
// Module Liste (remplace Courses)
'mod_liste_name' => 'Listes',
'mod_liste_desc' => 'Listes partagées nommées — courses, bricolage, pharmacie… Autant de listes que nécessaire.',
'menu_liste' => 'Listes',
'liste_placeholder_add' => 'Ajouter un article…',
'liste_btn_add' => 'Ajouter',
'liste_btn_uncheck_all' => 'Tout décocher',
'liste_btn_delete_picked' => 'Supprimer les cochés',
'liste_btn_clear_all' => 'Vider la liste',
'liste_subtitle' => '{pending} à faire · {cart} cochés',
'liste_added' => 'Ajouté',
'liste_updated' => 'Modifié',
'liste_deleted' => 'Supprimé',
'liste_already_in' => 'Déjà dans la liste',
'liste_confirm_clear' => 'Vider toute la liste ?',
'liste_confirm_delete_list' => 'Supprimer cette liste et tous ses articles ?',
'liste_history_title' => 'Récemment utilisés — clic pour rajouter',
'liste_new_list' => 'Nouvelle liste',
'liste_list_name_placeholder'=> 'Nom de la liste…',
'liste_create' => 'Créer',
'liste_rename_list' => 'Renommer',
'liste_new_name' => 'Nouveau nom :',
'liste_list_deleted' => 'Liste supprimée',
'liste_list_created' => 'Liste créée',
'liste_list_renamed' => 'Liste renommée',
'liste_edit_placeholder' => 'Modifier l\'article :',
'liste_empty_state' => 'Créez votre première liste avec le bouton + ci-dessus.',
'liste_settings_title' => 'Listes — historique',
'liste_settings_intro' => 'Nombre maximum d\'articles mémorisés dans l\'historique (commun à toutes les listes).',
'liste_settings_history_max' => 'Taille de l\'historique',
'liste_settings_history_hint'=> 'Entre 1 et 50.',
'groceries_settings_saved' => 'Préférences enregistrées.',
'groceries_settings_error' => 'Impossible d\'enregistrer :',
'groceries_err_duplicate' => 'Ce produit est déjà dans la liste.',
'mod_groceries_name' => 'Courses',
'mod_groceries_desc' => 'Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.',
'mod_calendar_ios_name' => 'Calendrier iOS',
'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).',
'mod_printvault_name'=> 'PrintVault',
'mod_printvault_desc'=> 'Gérez vos fichiers d\'impression 3D — STL, 3MF, GCode avec viewer interactif.',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Gérez vos projets en mode Kanban avec Planka.',
'cta_open' => 'Ouvrir',
'cta_explore' => 'Explorer',
'cta_view_lists' => 'Déballer',
@@ -148,15 +115,52 @@ return [
'cta_check' => 'Cocher',
'cta_cart' => 'Faire les courses',
'cta_sync' => 'Synchroniser',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Gérez vos fichiers d\'impression 3D — STL, 3MF, GCode avec viewer interactif.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Imprimer',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Gérez vos projets en mode Kanban avec Planka.',
'menu_planka' => 'Planka',
// --- SETTINGS : ENFANTS ---
// ==========================================
// 4. PARAMÈTRES (SETTINGS)
// ==========================================
'set_title_page' => '⚙️ Paramètres',
'set_title_lang' => '🌐 Langue / Language',
'set_title_modules' => '🧩 Modules actifs',
'set_desc_modules' => 'Choisissez les modules visibles dans la navigation.',
'set_title_ios' => '📱 Intégration Calendrier iOS (CalDAV)',
'set_desc_ios' => 'Configurez ici votre calendrier iCloud pour synchroniser les événements.',
'set_label_apple_id' => 'Identifiant Apple (email iCloud)',
'set_label_apple_pwd' => 'Mot de passe d\'app Apple',
'set_label_apple_url' => 'URL du calendrier CalDAV',
'set_btn_test_ios' => 'Tester la connexion',
'set_btn_disconnect_ios' => 'Déconnecter',
'set_title_bg' => '🖼️ Image d\'accueil',
'set_desc_bg' => 'Photo de fond affichée sur la page d\'accueil.',
'set_badge_custom_bg' => 'Image personnalisée',
'set_label_new_bg' => 'Nouvelle image (jpg, png, webp — max 10 Mo)',
'set_btn_reset_bg' => '🔄 Remettre l\'image par défaut',
'set_confirm_reset_bg' => 'Revenir à l\'image par défaut ?',
'set_title_profile' => '👤 Mon profil',
'set_label_username' => 'Nom d\'utilisateur',
'set_label_display_name' => 'Prénom / Pseudo affiché',
'set_label_profile_color' => 'Couleur thématique de mon profil',
'set_desc_profile_color' => 'Cette couleur sera utilisée pour vos grilles de budget, calendriers et indicateurs personnels.',
'set_title_pwd' => '🔒 Changer le mot de passe',
'set_label_cur_pwd' => 'Mot de passe actuel',
'set_label_new_pwd' => 'Nouveau mot de passe',
'set_label_confirm_pwd' => 'Confirmer',
'set_btn_edit' => 'Modifier',
'set_title_family' => '🏠 Mon espace familial',
'set_label_family_name' => 'Nom de l\'espace',
'set_desc_invite' => 'Code d\'invitation — partagez-le pour inviter quelqu\'un',
'set_title_copy' => 'Cliquer pour copier',
'set_msg_copied' => '✓ Copié !',
'set_btn_new_code' => '🔄 Nouveau code',
'set_confirm_new_code' => 'Regénérer le code ? L\'ancien sera invalidé.',
'set_desc_register_link' => 'Lien d\'inscription :',
'set_title_members' => 'Membres',
'set_badge_admin' => 'Admin',
'set_title_attached' => '🧸 Profils rattachés',
'set_desc_attached' => 'Membres gérés par le foyer n\'ayant pas d\'accès direct.',
'set_empty_attached' => 'Aucun profil rattaché.',
'set_link_admin' => '→ Panneau d\'administration',
'set_kids_title' => 'Enfants du foyer',
'set_kids_desc' => 'Gérez la liste des enfants pour les associer à l\'épargne, aux cadeaux, etc.',
'set_add_kid_placeholder' => 'Prénom de l\'enfant',
@@ -170,8 +174,65 @@ return [
'set_add_care_mode' => 'Ajouter un mode',
'btn_save_config' => 'Enregistrer',
// Labels Profils
'label_firstname' => 'Prénom',
'placeholder_firstname_ex' => 'Ex: Pol, Carole...',
'label_role' => 'Rôle',
'role_child' => '👶 Enfant',
'role_child_icon' => '👶 Enfant',
'role_helper' => '💼 Intervenant',
'role_helper_icon' => '💼 Intervenant',
'role_relative' => '👵 Proche',
'role_relative_icon' => '👵 Proche',
'role_unknown' => 'Inconnu',
'label_color' => 'Couleur',
'change_color' => 'Changer la couleur',
'confirm_delete_profile' => 'Supprimer définitivement ce profil ?',
// Messages Settings
'error_csrf' => 'Session invalide (CSRF). Rechargez la page.',
'set_err_min_module' => 'Vous devez garder au moins un module actif.',
'set_success_modules' => 'Modules mis à jour avec succès.',
'set_success_lang' => 'Langue mise à jour.',
'set_err_empty_name' => 'Le prénom ne peut pas être vide.',
'set_success_profile' => 'Profil mis à jour avec succès.',
'set_err_wrong_pwd' => 'Mot de passe actuel incorrect.',
'set_err_pwd_length' => 'Le nouveau mot de passe doit faire au moins 6 caractères.',
'set_err_pwd_match' => 'Les mots de passe ne correspondent pas.',
'set_success_pwd' => 'Mot de passe modifié avec succès.',
'set_success_family' => 'Nom de l\'espace mis à jour.',
'set_success_invite' => 'Nouveau code d\'invitation généré.',
'set_err_bg_format' => 'Format non supporté (jpg, png, webp).',
'set_success_bg' => 'Image d\'accueil mise à jour.',
'set_err_upload' => 'Erreur lors de l\'upload.',
'set_success_bg_reset' => 'Image d\'accueil réinitialisée.',
'set_err_db_unavailable' => 'Base famille indisponible.',
'set_err_ios_missing' => 'Merci de renseigner identifiant iCloud, mot de passe d\'app et URL CalDAV.',
'set_success_ios' => 'Connexion calendrier iOS enregistrée.',
'set_success_ios_auto' => ' URL du calendrier détectée automatiquement.',
'set_err_ios_save' => 'Impossible d\'enregistrer la connexion iOS : ',
'set_err_ios_none' => 'Aucune connexion iOS configurée.',
'set_success_ios_test' => 'Connexion iCloud CalDAV valide (HTTP %s).',
'set_err_ios_test' => 'Test connexion échoué (HTTP %s). Vérifiez vos identifiants.',
'set_err_ios_test_fail' => 'Test connexion impossible : ',
'set_success_ios_del' => 'Connexion iOS supprimée.',
'set_success_profile_add' => 'Profil de %s ajouté avec succès.',
'set_err_name_required' => 'Le prénom est obligatoire.',
'set_success_profile_del' => 'Profil supprimé.',
'set_success_color' => 'Couleur mise à jour !',
'set_success_color_profile' => 'Couleur du profil mise à jour.',
'set_success_color_update' => 'Couleur mise à jour !',
'color_updated' => 'Couleur mise à jour !',
'settings_profile_added' => 'Profil de %s ajouté avec succès.',
'settings_err_name_required' => 'Le prénom est obligatoire.',
'settings_attached_profiles_title' => 'Profils rattachés',
'settings_attached_profiles_desc' => 'Membres gérés par le foyer (Enfants, Nounous, Proches) n\'ayant pas d\'accès direct.',
'settings_no_attached_profiles' => 'Aucun profil rattaché.',
'placeholder_apple_id' => 'nom@icloud.com',
'placeholder_apple_pwd' => 'xxxx-xxxx-xxxx-xxxx',
// ==========================================
// FAMILY CALENDAR
// 5. CALENDRIER FAMILIAL
// ==========================================
'fc_page_title' => 'HouseHub - Calendrier',
'fc_main_header' => 'Calendrier Familial',
@@ -196,6 +257,7 @@ return [
'fc_weekly_planning' => 'Planning hebdomadaire',
'fc_col_month' => 'Mois',
'fc_col_week_short' => 'Sem.',
'fc_col_week' => 'Semaine',
'fc_col_av' => 'Av.',
'fc_col_use' => 'Use',
'fc_legend_title' => 'Légende',
@@ -206,106 +268,59 @@ return [
'fc_unit_days' => 'jours',
'fc_menu_carole' => 'Congés Carole',
'fc_clear' => 'Effacer',
'fc_menu_kids_leaves' => 'Congés Enfants',
'fc_menu_kids_leaves' => 'Congés Parents',
'fc_err_save_snap' => 'Erreur lors de la sauvegarde du correctif.',
'fc_err_fetch_gov' => 'Erreur lors de la connexion à l\'API gouvernementale.',
'fc_err_no_data_gov' => 'Aucune donnée trouvée sur l\'API du gouvernement pour cette année.',
'fc_err_action' => 'Erreur lors de l\'action : ',
'fc_err_select_leave' => 'Veuillez sélectionner un type de congé.',
'fc_today_short' => 'Auj.',
'fc_today_title' => "Aujourd'hui",
'care_nounou' => 'Nounou',
'care_centre' => 'Centre de loisirs',
'care_avis' => 'Avis',
'fc_tab_foyer' => '🏡 Foyer',
'fc_tab_members' => '👥 Membres & Droits',
'fc_select_member' => 'Sélectionner un membre de la famille :',
'fc_role_adult' => 'Adulte',
'fc_role_child' => 'Enfant',
'fc_care_modes_title' => 'Modes de garde associés',
'fc_care_modes_desc' => 'Cochez les modes de garde qui concernent cet enfant.',
'fc_no_care_mode' => 'Aucun mode configuré.',
'fc_matrix_title' => '👨‍👩‍👦 Matrice des Droits aux Congés',
'fc_matrix_desc' => 'Déclarez les codes de compteurs et dates de renouvellement.',
'fc_matrix_code' => 'Code',
'fc_matrix_date' => 'Date Reset',
'fc_matrix_empty' => 'Aucun compteur configuré.',
'fc_matrix_saved' => 'Matrice des droits mise à jour ! 💼',
'fc_child_saved' => 'Abonnement enfant enregistré !',
'btn_add_counter' => '➕ Ajouter',
'btn_save_rights' => '💾 Enregistrer',
'fc_modal_settings_title' => 'Paramètres du Calendrier',
'fc_zone_label' => 'Zone Scolaire (Vacances)',
'fc_zone_desc' => 'Détermine le calendrier automatique des vacances scolaires.',
'fc_care_modes_label' => 'Modes de Garde du Foyer',
'fc_care_modes_desc' => 'Gérez la liste globale des modes de garde possibles pour la famille.',
'fc_add_mode_placeholder' => 'Ex: Nounou, Casal, Grands-parents...',
'fc_settings_updated' => 'Paramètres mis à jour avec succès !',
'fc_first_day' => 'Premier jour de la semaine',
// --- CALENDRIER : PARAMÈTRES (MEMBRES & AFFICHAGE) ---
'fc_tab_display' => '🖥️ Affichage',
'fc_display_title' => 'Paramètres d\'affichage',
'fc_display_desc' => 'Personnalisez l\'apparence et le comportement par défaut de votre calendrier.',
'fc_default_view' => 'Vue par défaut',
'fc_view_month' => '1 mois',
'fc_view_2months' => '2 mois',
'fc_view_3months' => '3 mois',
'fc_working_hours' => 'Horaires de garde (Défaut)',
'fc_working_hours_desc' => 'Exemple : 08:30-18:00',
'fc_role_unknown' => 'Inconnu',
'fc_select_member' => 'Sélectionner un membre :',
'fc_role_adult' => 'Adulte',
'fc_role_child' => 'Enfant',
'fc_role_helper' => 'Intervenant',
'fc_error' => 'Erreur : ',
'fc_role_unknown' => 'Inconnu',
'fc_care_modes_title' => 'Modes de Garde Par Défaut',
'fc_care_modes_desc' => 'Quels modes de garde s\'appliquent à cet enfant ?',
'fc_no_care_mode' => 'Aucun mode de garde configuré dans le foyer.',
'fc_child_saved' => 'Sauvegardé !',
'fc_matrix_title' => 'Matrice des Congés',
'fc_matrix_desc' => 'Définissez les quotas et la date de renouvellement.',
'fc_matrix_code' => 'Code',
'fc_matrix_date' => 'Date Reset',
'fc_matrix_empty' => 'Aucun compteur défini.',
'fc_matrix_saved' => 'Matrice enregistrée !',
'fc_leave_obsolete' => 'Obsolète',
'fc_leave_code_placeholder' => 'Code...',
'fc_leave_method_fixed' => 'Fixe',
'fc_leave_method_accumulated' => 'Cumul',
'fc_date_format_placeholder' => 'JJ/MM',
'fc_date_format_title' => 'Format JJ/MM',
'fc_table_code' => 'Code',
'fc_table_method' => 'Méthode',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renouv.',
'btn_save_rights' => 'Enregistrer',
// ==========================================
// MODALE PARAMÈTRES CALENDRIER
// ==========================================
'fc_child_saved' => 'Sauvegardé !',
'fc_modal_settings_title' => 'Paramètres du calendrier',
'fc_tab_foyer' => 'Foyer',
'fc_tab_members' => 'Membres & Droits',
// Foyer : Zone scolaire
'fc_zone_label' => 'Zone scolaire',
'fc_zone_a' => 'Zone A',
'fc_zone_b' => 'Zone B',
'fc_zone_c' => 'Zone C',
'fc_zone_other' => 'Autre / Hors France',
'fc_zone_desc' => 'Permet d\'afficher correctement les vacances scolaires.',
// Foyer : Modes de garde
'fc_care_modes_label' => 'Modes de garde',
'fc_add_mode_placeholder' => 'Ex: Nounou, Crèche...',
'fc_care_modes_desc' => 'Ajoutez les différents modes de garde pour vos enfants.',
'fc_empty_kids_msg' => 'Aucun enfant n\'est configuré dans votre famille pour le moment.',
'fc_link_settings' => 'Aller aux paramètres généraux',
// Foyer : Catalogue des congés
'fc_settings_updated' => 'Paramètres mis à jour avec succès !',
'fc_first_day' => 'Premier jour de la semaine',
'fc_helper_recap' => 'Récapitulatif Intervenant',
'fc_no_helper_leaves' => 'Aucun congé posé ce mois-ci.',
'fc_col_date' => 'Date',
'fc_col_type' => 'Type',
'fc_col_duration' => 'Durée',
'fc_full_year' => 'Année complète',
'fc_custom_period' => 'Période spécifique...',
'fc_worked_days' => 'Travaillés (Est.)',
'fc_tab_display' => '🖥️ Affichage',
'fc_display_title' => 'Paramètres d\'affichage',
'fc_display_desc' => 'Personnalisez l\'apparence et le comportement par défaut de votre calendrier.',
'fc_default_view' => 'Vue par défaut',
'fc_working_hours' => 'Horaires de garde (Défaut)',
'fc_working_hours_desc' => 'Exemple : 08:30-18:00',
'fc_error' => 'Erreur : ',
'fc_leave_catalog_title' => 'Catalogue des congés',
'fc_leave_catalog_desc' => 'Définissez les types de congés possibles pour toute la famille.',
'fc_add_leave_type' => 'Ajouter un type de congé',
@@ -315,25 +330,34 @@ return [
'fc_leave_label_ph' => 'Ex: RTT, Récup...',
'fc_quota_default' => 'Quota par défaut',
'fc_reset_month' => 'Mois de renouv.',
'fc_leave_obsolete' => 'Obsolète',
'fc_leave_code_placeholder' => 'Code...',
'fc_leave_method_fixed' => 'Fixe',
'fc_leave_method_accumulated'=> 'Cumul',
'fc_date_format_placeholder'=> 'JJ/MM',
'fc_date_format_title' => 'Format JJ/MM',
'fc_table_code' => 'Code',
'fc_table_method' => 'Méthode',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renouv.',
'fc_label_carry_over' => 'Dont jours reportés (Périssables) ⚠️',
'fc_carry_over_note' => 'Partie du solde global qui expirera à la date butoir définie.',
'fc_carry_max' => 'Max Reportable',
'fc_carry_deadline' => 'Mois Expiration',
'fc_deadline_none' => 'Aucune',
'fc_allowance_help' => 'Fixe: Quota annuel. Graduel: Ajout par mois.',
'fc_carry_max_short' => 'Report max',
'fc_edit_leave' => '✏️ Modifier :',
'fc_not_modifiable' => 'Non modifiable',
'fc_add_leave' => '+ Ajouter',
'fc_err_code_label' => 'Le code et le libellé sont obligatoires.',
'fc_catalog_updated' => 'Catalogue mis à jour !',
'fc_balance_corrected' => 'Solde corrigé !',
'fc_no_leave_configured' => 'Aucun congé configuré.',
'fc_confirm_del_catalog' => 'Supprimer définitivement le congé %s du catalogue ? Cela le retirera également des membres qui l\'utilisent.',
'fc_confirm_del_member_leave'=> 'Voulez-vous vraiment retirer ce congé pour ce membre ?',
// Membres
'fc_select_member' => 'Sélectionner un membre :',
// Mois (S'ils n'existent pas déjà dans ton système)
'month_jan' => 'Janvier',
'month_feb' => 'Février',
'month_mar' => 'Mars',
'month_apr' => 'Avril',
'month_may' => 'Mai',
'month_jun' => 'Juin',
'month_jul' => 'Juillet',
'month_aug' => 'Août',
'month_sep' => 'Septembre',
'month_oct' => 'Octobre',
'month_nov' => 'Novembre',
'month_dec' => 'Décembre',
// Légendes Calendrier
'leg_presence' => 'Présence Pep',
'leg_school_holidays' => 'Vacances Scolaires',
'leg_public_holiday' => 'Férié',
@@ -341,7 +365,10 @@ return [
'leg_extra_off' => 'Extra Off',
'leg_centre' => 'Centre',
'leg_avis' => 'Avis',
'leg_pep_sick' => 'Pep Malade',
'fc_menu_sick' => 'Malade',
'care_nounou' => 'Nounou',
'care_centre' => 'Centre de loisirs',
'care_avis' => 'Avis',
'vac_toussaint' => 'Vacances de la Toussaint',
'vac_noel' => 'Vacances de Noël',
@@ -351,12 +378,11 @@ return [
'vac_ete' => "Vacances d'Été",
'btn_off' => 'Off',
'btn_extra' => 'Extra',
'fc_alert_burn_days' => '⚠️ Risque de perte : %s j à poser avant le %s',
'fc_alert_burn_jra' => '⚠️ JRA : Seuls 2j sont reportables après février !',
'fc_alert_burn_days' => '⚠️ Risque de perte : %s j à poser avant la fin de cycle',
'fc_alert_burn_jra' => '⚠️ Alerte : Jours reportés expirent à la fin de ce mois !',
// ==========================================
// HOLIDAYS (VOYAGES)
// 6. VOYAGES (HOLIDAYS)
// ==========================================
'hdl_title_list' => 'HouseHub - Mes Vacances',
'hdl_title_detail' => 'HouseHub - Détail du voyage',
@@ -431,9 +457,8 @@ return [
'hdl_to_place' => 'À placer',
'hdl_from' => 'Du',
'hdl_to' => 'Au',
'hdl_btn_add_step' => 'Ajouter une étape',
'hdl_quick_edit_title' => 'Modification rapide',
'hdl_paid' => 'Payé',
// Météo et spécifiques Holidays
'weather_forecast' => 'Prévisions météo',
'weather_not_available' => 'Météo indisponible',
'temp_max' => 'Temp. Max',
@@ -448,7 +473,7 @@ return [
'hdl_default_exp_name' => 'Dépense',
'hdl_select_beneficiary' => '-- Sélectionner --',
// Anciennes clés Holidays non préfixées (conservées pour compatibilité si utilisées)
// Anciennes clés (conservées au cas où)
'outbound_trip' => 'Aller',
'return_trip' => 'Retour',
'period' => 'Période',
@@ -501,7 +526,7 @@ return [
'to_place' => '📥 À Placer',
// ==========================================
// BUDGET (GLOBAL & CATEGORIES)
// 7. BUDGET (GLOBAL, SUIVI, PREV, EPARGNE, PROVISIONS)
// ==========================================
'budget_page_title' => 'HouseHub - Budget',
'budget_main_header' => 'Gestion du Budget',
@@ -509,9 +534,16 @@ return [
'budget_tab_prev' => 'Budget 2026',
'budget_tab_savings' => 'Épargne',
'budget_tab_recap' => 'Récapitulatif',
'budget_tab_provisions' => 'Provisions',
'budget_tab_kids' => 'Enfants',
'budget_err_file_not_found' => 'Oups ! Fichier introuvable.',
'budget_err_file_detail' => 'Vérifiez que le fichier views/%s.php existe bien.',
'budget_err_not_authorized' => 'Page non autorisée.',
'bud_err_tech' => 'Une erreur technique est survenue.',
'bud_err_server' => 'Erreur serveur : ',
'bud_err_delete' => 'Erreur lors de la suppression.',
// Catégories (Budget)
'cat_income' => 'Revenus',
'cat_fmcg' => 'Courses (FMCG)',
'cat_fuel' => 'Essence',
@@ -519,12 +551,14 @@ return [
'cat_fixed' => 'Charges Fixes',
'cat_others' => 'Autres / Imprévus',
'cat_savings' => 'Épargne',
'budget_tab_kids' => 'Enfants',
// Paramètres (Budget Settings)
'budget_settings_title' => 'Paramètres du Budget',
'bs_tab_accounts' => '💳 Comptes',
'bs_tab_categories' => '🏷️ Catégories',
'bs_tab_rules' => '🤖 Règles d\'import',
'bs_tab_salaries' => '💼 Salaires',
'bs_tab_csv' => '📥 Import CSV',
'bs_empty_accounts' => 'Aucun compte configuré.',
'bs_type_checking' => 'Courant',
'bs_type_savings' => 'Épargne',
@@ -554,7 +588,23 @@ return [
'bs_currency_label' => 'Devise du Foyer',
'bs_currency_desc' => 'Devise principale utilisée pour l\'affichage de vos comptes et budgets.',
'bs_currency_updated' => 'Devise mise à jour avec succès !',
// ONGLET CSV MAPPING
'bud_settings_dyn_title' => 'Estimations Dynamiques (Enfants)',
'bud_dyn_nanny_fixed' => 'Salaire fixe Assistante Maternelle (€)',
'bud_dyn_nanny_daily' => 'Frais d\'entretien journaliers (€)',
'bud_dyn_nanny_aid' => 'Aide de l\'État (CMG) (€)',
'bud_dyn_nanny_cesu_avg' => 'Moyenne CESU versée (€)',
'bud_dyn_school_meal' => 'Prix repas Cantine (€)',
'bud_dyn_school_aftercare' => 'Prix Accueil Périscolaire soir (€)',
'bud_dyn_school_fullday' => 'Prix Accueil Journée (Mercredi/Vacances) (€)',
'bud_dyn_pattern_title' => 'Semaine type d\'école',
'bud_audit_month' => 'Mois analysé : %02d/%d',
'bud_audit_nanny_abs' => 'Absences %s déduites : %dj',
'bud_audit_child' => '👦 %s :',
'bud_audit_school_fee' => 'Cantine (%dj) + Garderie (%dj) + Centre (%dj) = %.2f€',
'bud_audit_sick' => 'Jours maladie déduits : %dj',
'bud_audit_nanny_full' => '💼 %s : Fixe (%.2f€) + Frais (%dj x %.2f€ = %.2f€) - Aide (%.2f€) - CESU (%.2f€) = %.2f€',
// CSV Mapping (Budget)
'bs_csv_title' => 'Format de l\'import CSV',
'bs_csv_desc' => 'Configurez ici les colonnes correspondantes à l\'export de votre banque.',
'bs_csv_delimiter' => 'Séparateur',
@@ -575,7 +625,6 @@ return [
'bs_csv_col_ref' => 'N° Col : Référence unique (Optionnel)',
'btn_save_format' => 'Enregistrer le format',
'bs_csv_saved' => 'Format CSV enregistré',
'bs_tab_csv' => '📥 Import CSV',
'bs_csv_settings_title' => 'Configuration du format',
'bs_csv_drop_title' => 'Glissez votre fichier CSV ici',
'bs_csv_drop_desc' => 'ou cliquez pour parcourir vos fichiers',
@@ -585,7 +634,22 @@ return [
'bs_csv_empty_col' => 'Vide',
'bs_csv_no_data' => 'Aucune ligne de transaction trouvée dans ce fichier.',
// --- BUDGET : SUIVI ---
// Onboarding Budget
'bs_onb_title' => 'Bienvenue ! Configurons votre budget :',
'bs_onb_desc' => 'Complétez ces étapes pour que l\'application puisse calculer votre Reste à Vivre :',
'bs_onb_cat_link' => 'Catégories',
'bs_onb_cat_desc' => 'Créez au moins une catégorie de dépenses.',
'bs_onb_acc_link' => 'Comptes Bancaires',
'bs_onb_acc_desc' => 'Ajoutez au moins un compte courant ou épargne.',
'bs_onb_sal_link' => 'Salaires',
'bs_onb_sal_desc' => 'Renseignez les rentrées d\'argent de l\'année en cours.',
'bs_onb_items_link' => 'Lignes de Budget',
'bs_onb_items_desc' => 'Rendez-vous dans l\'onglet Récapitulatif pour lister vos charges fixes (Loyer, Assurances...).',
'bs_rule_search_ph' => '🔍 Rechercher un mot-clé (ex: AUCHAN)...',
'error_loading_settings' => 'Erreur lors du chargement des paramètres',
'bud_err_delete_system_cat' => 'Impossible de supprimer une catégorie système essentielle.',
// Budget : Suivi
'bud_rem_school' => 'École (Reste estimé)',
'bud_rem_fmcg' => 'Courses (Reste estimé)',
'bud_rem_fuel' => 'Essence (Reste estimé)',
@@ -638,41 +702,12 @@ return [
'bud_add_title' => 'Ajouter',
'bud_edit_title' => 'Modifier la transaction',
'bud_to_define_js' => 'à définir',
'bud_bar_actual' => "Actuel",
'bud_bar_theoretical' => "Théorique",
'bud_bar_max_cap' => "Capacité Max",
'bud_import_csv' => "Importer CSV",
'bud_csv_file' => "Sélectionnez le fichier de votre banque (.csv)",
'bud_preview' => "Prévisualiser",
'bud_validate_import' => "Veuillez vérifier et catégoriser les lignes avant validation.",
'bud_assign_month' => "Mois de gestion",
'bud_op_date' => "Date",
'bud_amount' => "Montant",
'bud_ignore' => "Ignorer (Crédit)",
'bud_to_define' => "À définir",
'bud_is_charge' => "Est une charge fixe...",
'bud_is_income' => "Est un revenu...",
'bud_edit_title' => 'Modifier la transaction',
'bud_to_define_js' => 'à définir',
'bud_select_beneficiary' => 'Choisir un revenu attendu',
'bud_bar_actual' => 'Actuel',
'bud_bar_theoretical' => 'Théorique',
'bud_bar_max_cap' => 'Capacité Max',
'bud_select_beneficiary' => 'Choisir un revenu attendu',
// ONBOARDING BUDGET
'bs_onb_title' => 'Bienvenue ! Configurons votre budget :',
'bs_onb_desc' => 'Complétez ces étapes pour que l\'application puisse calculer votre Reste à Vivre :',
'bs_onb_cat_link' => 'Catégories',
'bs_onb_cat_desc' => 'Créez au moins une catégorie de dépenses.',
'bs_onb_acc_link' => 'Comptes Bancaires',
'bs_onb_acc_desc' => 'Ajoutez au moins un compte courant ou épargne.',
'bs_onb_sal_link' => 'Salaires',
'bs_onb_sal_desc' => 'Renseignez les rentrées d\'argent de l\'année en cours.',
'bs_onb_items_link' => 'Lignes de Budget',
'bs_onb_items_desc' => 'Rendez-vous dans l\'onglet Récapitulatif pour lister vos charges fixes (Loyer, Assurances...).',
'bs_rule_search_ph' => '🔍 Rechercher un mot-clé (ex: AUCHAN)...',
'error_loading_settings' => 'Erreur lors du chargement des paramètres',
'settings' => 'Paramètres',
// --- BUDGET : RECAP ---
// Budget : Récapitulatif
'bud_recap_monthly_title' => 'Récapitulatif Mensuel',
'bud_recap_btn_add' => 'Ajouter un frais / revenu',
'bud_planned_amount' => 'Montant Prévu',
@@ -712,44 +747,10 @@ return [
'bud_label_regularization' => 'Régularisation',
'bud_reg_none' => 'Aucune',
'bud_recap_confirm_delete' => 'Voulez-vous vraiment supprimer cet élément ?',
'bud_err_delete' => 'Erreur lors de la suppression.',
'bud_prev_monthly_target' => 'Montant cible / Mensualité (€)',
'bud_prev_transfer_dest' => 'Destination du virement',
'bud_prev_none' => 'Aucune destination (Charge standard)',
// --- BUDGET : EPARGNE ---
'bud_sav_add_month' => 'Saisir un mois',
'bud_sav_add_one_month' => '+1 Mois',
'bud_sav_no_data' => 'Aucune donnée pour %s.',
'bud_sav_post_month' => 'Poste / Mois',
'bud_sav_from_date' => 'Dès le %s',
'bud_sav_edit_modal' => 'Modifier avec Modale',
'bud_sav_delete_month' => 'Supprimer tout le mois',
'bud_sav_total_bank' => 'Total Banque',
'bud_sav_extra' => 'Extra',
'bud_sav_modal_title_add' => 'Saisir un mois',
'bud_sav_modal_title_edit' => 'Modifier :',
'bud_sav_month_concerned' => 'Mois concerné',
'bud_sav_total_bank_eur' => 'Total en Banque (€)',
'bud_sav_ventilation' => 'Ventilation',
'bud_sav_adj_help' => 'Utilisez l\'ajustement (+/-) pour recalculer automatiquement.',
'bud_sav_add_line' => 'Ligne',
'bud_sav_current' => 'Actuel',
'bud_sav_adjust' => 'Ajust (+/-)',
'bud_sav_new' => 'Nouveau',
'bud_sav_sum_mode_title' => 'Activer l\'addition rapide',
'bud_sav_selection' => 'Sélection :',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_sav_saving' => 'Enregistrement...',
'bud_err_tech' => 'Une erreur technique est survenue.',
'bud_sav_confirm_delete_month' => 'Supprimer TOUT le mois de %m pour %o ?',
'bud_sav_prompt_duplicate' => "Voulez-vous copier les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
'bud_err_server' => 'Erreur serveur : ',
'bud_err_network_dup' => 'Erreur réseau lors de la duplication.',
'budget_tab_kids' => 'Enfants 👶',
'bud_sav_add_one_month' => '+1 Mois',
// Budget : Épargne
'bud_sav_add_month' => 'Ajouter un mois',
'bud_sav_add_one_month' => '+1 Mois',
'bud_sav_no_data' => 'Aucune donnée d\'épargne pour %s.',
'bud_sav_post_month' => 'Poste / Mois',
'bud_sav_from_date' => 'Dès le %s',
@@ -764,25 +765,20 @@ return [
'bud_sav_ventilation' => 'Ventilation des comptes',
'bud_sav_adj_help' => 'Saisissez les ajustements (+/-) pour recalculer automatiquement.',
'bud_sav_add_line' => 'Ajouter une ligne',
'bud_category' => 'Catégorie',
'bud_sav_current' => 'Actuel',
'bud_sav_adjust' => 'Ajustement',
'bud_sav_new' => 'Nouveau',
'bud_sav_sum_mode_title' => 'Activer/Désactiver la calculatrice',
'bud_sav_selection' => 'Sélection :',
'bud_sav_ph_name' => 'Nom du compte',
// JS & Erreurs
'bud_sav_new_line' => 'Nouvelle ligne',
'bud_sav_prompt_new_line' => 'Nom de la nouvelle ligne d\'épargne :',
'bud_sav_confirm_delete_month' => 'Voulez-vous vraiment supprimer toutes les données de %m pour %o ?',
'bud_sav_prompt_duplicate' => "Voulez-vous dupliquer les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
'bud_err_tech' => 'Une erreur technique est survenue.',
'bud_err_server' => 'Erreur serveur : ',
'bud_err_network_dup' => 'Erreur réseau lors de la duplication.',
'bud_sav_saving' => 'Sauvegarde en cours...',
'bud_err_delete' => 'Erreur lors de la suppression.',
// --- BUDGET : PREVISIONNEL ---
// Budget : Prévisionnel
'bud_prev_incomes' => 'Revenus',
'bud_prev_person' => 'Personne',
'bud_prev_salary' => 'Salaire',
@@ -816,9 +812,11 @@ return [
'bud_prev_err_no_history' => 'Impossible : pas d\'historique disponible pour copier.',
'bud_prev_confirm_copy' => "Voulez-vous copier les données de %s vers %t ?\n\n⚠️ Cela écrasera toutes les valeurs déjà présentes pour %t.",
'bud_prev_confirm_transfers' => "Confirmer que %p a bien effectué tous ses virements pour %m ?\n\nCela mettra à jour l'Épargne automatiquement.",
'bud_sav_modal_title_add' => 'Saisir un mois',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_prev_label_name' => 'Nom',
'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_transfer_to' => 'Vers',
// Budget : Tricount / Avances
'bud_adv_title' => 'Avances & Tricount Familial',
'bud_adv_add' => 'Ajouter une avance',
'bud_adv_edit' => 'Modifier l\'avance',
@@ -835,62 +833,55 @@ return [
'bud_adv_ph_desc' => 'Ex: Courses, Facture Électricité...',
'bud_adv_confirm_resolve' => 'Confirmer le remboursement complet de cette avance ?',
'bud_adv_confirm_delete' => 'Supprimer définitivement cette avance ?',
'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_transfer_to' => 'Vers',
// Budget : Provisions & Optimisation
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
'btn_optimize_cashflow' => 'Optimiser mes Économies',
'add_new_provision' => 'Anticiper une grosse dépense à venir',
'provision_label' => 'Libellé de la dépense',
'provision_placeholder_wood' => 'Ex: Commande bois de chauffage, Révision voiture...',
'amount' => 'Montant',
'expected_date' => 'Date prévue',
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
'savings_this_month' => 'Économies globales à placer ce mois-ci',
'calculate' => 'Calculer la stratégie optimale',
'date_past_warning' => 'La date sélectionnée est déjà passée !',
// ==========================================
// MODULE : CADEAUX (gift-list)
// 8. CADEAUX (GIFTS)
// ==========================================
// 1. Titres et en-têtes
'gift_page_title' => 'Liste des Cadeaux',
'gift_main_title' => 'Cadeaux de %s', // %s sera remplacé par l'année (ex: Cadeaux de 2026)
'settings' => 'Paramètres',
// 2. Filtres
'gift_main_title' => 'Cadeaux de %s',
'gift_filter_all_children' => 'Tous les enfants',
'gift_filter_all_adults' => 'Tous les adultes',
// 3. Contenu et états vides
'gift_add_gift' => 'Ajouter',
'gift_empty_state_no_gifts' => 'Aucun cadeau enregistré pour cette fête.',
'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond à vos filtres.',
'gift_paid_by' => 'Payé par %s', // %s sera remplacé par le nom du payeur
// 4. Section Tricount (Bilan & Remboursements)
'gift_paid_by' => 'Payé par %s',
'gift_liquidations' => 'Bilan & Remboursements',
'gift_no_debt' => 'Tout est à jour, aucune dette !',
'gift_owes' => 'doit',
'gift_view_matrix' => 'Voir la matrice détaillée',
'gift_debtor' => 'Débiteur',
'gift_creditor' => 'Créancier',
// 5. Modale : Ajout/Modification de Cadeau
'gift_col_adult' => 'Pour le compte de',
'gift_modal_payer' => 'Payé par',
'gift_modal_gift_name' => 'Description du cadeau',
'gift_modal_price' => 'Prix estimé / payé (€)',
'gift_modal_link' => 'Lien du produit (URL)',
'gift_modal_title_add' => 'Ajouter pour %s', // JS : %s = nom de l'enfant
'gift_modal_title_edit' => 'Modifier le cadeau', // JS
'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?', // JS
// 6. Modale : Paramètres des Fêtes (Nouveau !)
'gift_modal_title_add' => 'Ajouter pour %s',
'gift_modal_title_edit' => 'Modifier le cadeau',
'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?',
'gift_settings_title' => 'Configuration des Fêtes',
'gift_settings_add_title' => '+ Ajouter une occasion',
'gift_settings_name_placeholder' => 'Nom (ex: Saint Valentin)',
'gift_settings_date_placeholder' => 'MM-JJ',
'gift_settings_date_help' => 'La date (MM-JJ) est optionnelle. Laissez vide pour une fête mobile.',
'gift_settings_empty' => 'Aucune fête configurée.', // JS
'gift_settings_active' => 'Actif', // JS
// 7. Boutons génériques (si tu ne les as pas déjà en global)
'btn_cancel' => 'Annuler',
'btn_save' => 'Enregistrer',
'btn_add' => 'Ajouter',
'loading' => 'Chargement...',
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings_empty' => 'Aucune fête configurée.',
'gift_settings_active' => 'Actif',
'gift_settings' => 'Configuration',
'gift_tab_occasions' => 'Fêtes & Occasions',
'gift_tab_proches' => 'Proches (Hors foyer)',
@@ -907,9 +898,8 @@ return [
'gift_settings_edit_proche' => 'Modifier le proche',
// ==========================================
// GARAGE MANAGER
// 9. GARAGE
// ==========================================
'menu_garage' => 'Garage',
'garage_page_title' => 'HouseHub - Garage',
'garage_stat_vehicles' => 'Véhicules',
'garage_stat_maintenances' => 'Entretiens',
@@ -939,7 +929,6 @@ return [
'garage_photo' => 'Photo',
'garage_notes' => 'Notes',
'garage_maint_type' => 'Type d\'entretien',
'garage_date' => 'Date',
'garage_km_at' => 'Kilométrage au moment',
'garage_labor_cost' => 'Coût main d\'œuvre (€)',
'garage_description' => 'Description',
@@ -958,4 +947,79 @@ return [
'garage_change_photo' => 'Changer la photo',
'garage_new_photo' => 'Nouvelle photo',
'garage_update_photo' => 'Mettre à jour',
// ==========================================
// 10. LISTES & COURSES
// ==========================================
'liste_placeholder_add' => 'Ajouter un article…',
'liste_btn_add' => 'Ajouter',
'liste_btn_uncheck_all' => 'Tout décocher',
'liste_btn_delete_picked' => 'Supprimer les cochés',
'liste_btn_clear_all' => 'Vider la liste',
'liste_subtitle' => '{pending} à faire · {cart} cochés',
'liste_added' => 'Ajouté',
'liste_updated' => 'Modifié',
'liste_deleted' => 'Supprimé',
'liste_already_in' => 'Déjà dans la liste',
'liste_confirm_clear' => 'Vider toute la liste ?',
'liste_confirm_delete_list' => 'Supprimer cette liste et tous ses articles ?',
'liste_history_title' => 'Récemment utilisés — clic pour rajouter',
'liste_new_list' => 'Nouvelle liste',
'liste_list_name_placeholder'=> 'Nom de la liste…',
'liste_create' => 'Créer',
'liste_rename_list' => 'Renommer',
'liste_new_name' => 'Nouveau nom :',
'liste_list_deleted' => 'Liste supprimée',
'liste_list_created' => 'Liste créée',
'liste_list_renamed' => 'Liste renommée',
'liste_edit_placeholder' => 'Modifier l\'article :',
'liste_empty_state' => 'Créez votre première liste avec le bouton + ci-dessus.',
'liste_settings_title' => 'Listes — historique',
'liste_settings_intro' => 'Nombre maximum d\'articles mémorisés dans l\'historique (commun à toutes les listes).',
'liste_settings_history_max' => 'Taille de l\'historique',
'liste_settings_history_hint'=> 'Entre 1 et 50.',
'groceries_settings_saved' => 'Préférences enregistrées.',
'groceries_settings_error' => 'Impossible d\'enregistrer :',
'groceries_err_duplicate' => 'Ce produit est déjà dans la liste.',
// --- MODULE MEALS ---
'menu_meals' => 'Repas',
'mod_meals_name' => 'Menus',
'mod_meals_desc' => 'Planifiez les repas de la semaine pour toute la famille.',
'meals_board_title' => 'Planning Repas',
'meals_copy_lunch' => 'Copier le midi entier',
'meals_copy_dinner' => 'Copier le soir entier',
'meal_lunch' => 'Midi',
'meal_dinner' => 'Soir',
'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',
'meals_no_members' => 'Veuillez configurer les membres de votre famille dans les paramètres.',
'day_full_mon' => 'Lundi', 'day_full_tue' => 'Mardi', 'day_full_wed' => 'Mercredi',
'day_full_thu' => 'Jeudi', 'day_full_fri' => 'Vendredi', 'day_full_sat' => 'Samedi', 'day_full_sun' => 'Dimanche',
'meal_title' => 'Planificateur de Repas',
'meal_edit_title' => 'Modifier le repas',
'meal_label_name' => 'Menu prévu',
'meal_ph_name' => 'Ex: Lasagnes maison',
'meal_week' => 'Semaine',
'meal_empty_slot' => 'À définir...',
'meal_current_week' => 'Semaine en cours',
'meal_saved' => 'Repas enregistré !',
'meal_btn_import_pdf' => 'Importer Menu PDF',
'meal_import_title' => 'Importer un menu de cantine',
'meal_import_child' => 'Pour quel enfant ?',
'meal_select_child' => 'Sélectionner l\'enfant',
'meal_pdf_file' => 'Fichier PDF (Convivio)',
'meal_drop_pdf' => 'Cliquez pour ajouter le PDF',
'meal_import_success' => 'Menus importés avec succès !',
'mod_food_name' => 'Cuisine & Courses',
'food_tab_meals' => 'Menu de la semaine',
'food_tab_list' => 'Liste de courses',
'menu_food' => 'Food',
'mod_food_desc' => 'Gérez vos menus de la semaine et vos listes de courses partagées.',
'cta_food' => 'Mijoter',
];
+18 -2
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';
$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;
+68 -36
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'];
foreach ($families as $fam) {
$dbName = $fam['db_name'];
echo "Migration de la base : $dbName ... <br>";
// --- 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 {
$pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$famPdo = new PDO("mysql:host=$meta_host;dbname=$dbName;charset=utf8mb4", $meta_user, $meta_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
// 1. Mise à jour pf_holidays
$pdo->exec("ALTER TABLE pf_holidays ADD COLUMN return_step_id INT DEFAULT NULL");
// 2. Mise à jour pf_holidays_items
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN step_type VARCHAR(20) DEFAULT 'stop'");
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN expense_context VARCHAR(20) DEFAULT NULL");
// 3. Migration des données (is_return -> return_step_id)
// On cherche la première étape cochée "is_return" et on l'assigne au voyage
$pdo->exec("
UPDATE pf_holidays h
SET return_step_id = (
SELECT id FROM pf_holidays_items i
WHERE i.holiday_id = h.id AND i.is_return = 1 AND i.location_name IS NOT NULL
LIMIT 1
)
");
// 4. Nettoyage de l'ancienne colonne
$pdo->exec("ALTER TABLE pf_holidays_items DROP COLUMN is_return");
echo "<li>✅ {$family['name']} : Schéma Voyages mis à jour !</li>";
// 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 "<li>⚠️ {$family['name']} : " . $e->getMessage() . "</li>";
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());
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]);
}
+45 -4
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,6 +46,13 @@ 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' => [
@@ -52,9 +60,11 @@ try {
'categories' => $categories,
'rules' => $rules,
'salaries' => $salaries,
'people' => $people,
'year' => $currentYear,
'currency' => $currencySetting,
'csv_mapping' => $csvMapping
'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;
+140 -138
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>
<?php endif; ?>
<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; ?>
</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>
<div class="epargne-grid-table" style="--cols: <?= count($months) ?>; --owner-color: <?= htmlspecialchars($ownerColor) ?>;">
<div class="eg-header-row">
<div class="eg-cell eg-sticky"><?= tr('bud_sav_post_month') ?></div>
<?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>
<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 style='font-size:0.75rem; font-weight:normal; color:#64748b;'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</span>";
echo "<span class='month-cycle-start'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</span>";
}
?>
</div>
<div class="month-actions" style="justify-content: center; width: 100%;">
<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" title="<?= tr('bud_sav_delete_month') ?>"
onclick="deleteEntireMonth('<?= $month ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')"
style="color: #ef4444; border-color: #fca5a5; background: #fef2f2;">
<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>
</th>
</div>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<tr class="row-total">
<td class="sticky-col"><strong><?= tr('bud_sav_total_bank') ?></strong></td>
</div>
<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;
?>
<td class="text-center" style="padding:4px;">
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
<div class="eg-cell">
<div class="eg-cell-content">
<input type="number" step="0.01"
class="prev-input total-input-<?= $safeOwnerCls ?>-<?= $month ?>"
style="width: 70px; font-weight:bold; color:#2563eb;"
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 style="color:#2563eb; font-weight:bold; font-size:0.9rem;">€</span>
<span class="eg-currency-symbol">€</span>
</div>
</div>
</td>
<?php endforeach; ?>
</tr>
</div>
<?php foreach ($allCategories as $cat): ?>
<tr>
<td class="sticky-col"><?= htmlspecialchars($cat) ?></td>
<div class="eg-row">
<div class="eg-cell eg-sticky"><?= htmlspecialchars($cat) ?></div>
<?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;">
<div class="eg-cell">
<input type="number" step="0.01"
class="prev-input <?= $ownerTextClass ?> cat-input-<?= $safeOwnerCls ?>-<?= $month ?>"
style="width: 70px;"
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>
</td>
<?php endforeach; ?>
</tr>
</div>
<?php endforeach; ?>
<tr class="row-extres">
<td class="sticky-col"><strong><?= tr('bud_sav_extra') ?></strong></td>
<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;
?>
<td class="text-center font-bold sum-target" id="extra_<?= $safeOwnerCls ?>_<?= $month ?>" style="color: <?= $extra >= 0 ? '#10b981' : '#ef4444' ?>; padding:12px;">
<div class="eg-cell font-bold sum-target <?= $extra >= 0 ? 'text-success' : 'text-danger' ?>" id="extra_<?= $safeOwnerCls ?>_<?= $month ?>">
<?= number_format($extra, 0, ',', ' ') ?> €
</td>
</div>
<?php endforeach; ?>
</tr>
</tbody>
</table>
</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): ?>
+162 -25
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');
@@ -253,6 +314,16 @@ 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'));
// 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);
@@ -260,6 +331,16 @@ async function loadBudgetSettingsData() {
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>
+74 -54
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,23 +446,23 @@ $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;">
<div class="action-toolbar bud-toolbar">
<?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;">
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="openSuiviModal('importCsvModal')">
📂 <?= tr('bud_import_csv') ?? 'Importer CSV' ?>
</button>
@@ -452,12 +473,12 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<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>
<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" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔓 <?= tr('bud_reopen_btn') ?></button>
<button type="submit" class="pf-btn btn-secondary pf-btn-small">🔓 <?= tr('bud_reopen_btn') ?></button>
</form>
<?php endif; ?>
@@ -469,38 +490,38 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
</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;">
<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')" 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>
<button onclick="openSuiviModal('snapshotModal')" class="bud-kpi-edit-btn">✏️ <?= tr('bud_update_btn') ?></button>
<?php endif; ?>
</div>
<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' ?>;">
<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 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;">
<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')" 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>
<button onclick="toggleDiv('pendingDetailsList')" class="bud-pending-toggle">👁️</button>
</div>
<div style="font-size:1.4rem; font-weight:700; color:#b91c1c;">
<div class="bud-kpi-value bud-kpi-value--danger">
- <?= 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;">
<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 style="display:flex; justify-content:space-between; margin-bottom:6px;" <?= !empty($pc['tooltip']) ? 'title="'.htmlspecialchars($pc['tooltip']).'" style="cursor:help;"' : '' ?>>
<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>
@@ -534,28 +555,28 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
}
?>
<div style="margin-top: 65px; margin-bottom: 75px; position: relative; font-family: inherit;">
<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);">
<div class="bud-scale-wrapper">
<div class="bud-scale-track">
<?php if ($posZero > 0): ?>
<div style="position: absolute; left: 0; width: <?= $posZero ?>%; height: 100%; background: #fef2f2; border-radius: 12px 0 0 12px;"></div>
<div class="bud-scale-zero" style="width: <?= $posZero ?>%;"></div>
<?php endif; ?>
<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>
<div class="bud-scale-bar" style="left: <?= $leftActuel ?>%; width: <?= $widthActuel ?>%; border-radius: <?= $solde_actuel >= 0 ? '0 12px 12px 0' : '12px' ?>;"></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>
<div class="bud-scale-bar--danger" style="left: <?= $leftDanger ?>%; width: <?= $widthDanger ?>%;"></div>
<?php endif; ?>
</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--zero" style="left: <?= $posZero ?>%;"></div>
<div class="bud-scale-label bud-scale-label--zero" style="left: <?= $posZero ?>%;">0 €</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>
<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: <?= $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 $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>
<?php
$overlapMax = abs($posMax - $posActuel) < 35;
@@ -563,17 +584,16 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
$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 class="bud-scale-marker bud-scale-marker--max" style="left: <?= $posMax ?>%; top: <?= $topMaxLine ?>; height: <?= $heightMaxLine ?>;"></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)) ?>">
<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;">
<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;">
@@ -588,27 +608,27 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<?php endif; ?>
</div>
<?php $barCol = ($conf['db_type'] === 'Income') ? '#10b981' : ($logic['isOver'] ? '#ef4444' : $conf['color']); ?>
<div style="background:#f1f5f9; height:4px; width:100%;">
<?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:#cbd5e1; font-style:italic; font-size:0.85rem;"><?= tr('bud_no_lines') ?></div>
<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 style="width:100%; border-collapse:collapse; font-size:0.85rem;">
<table class="bud-expense-table">
<?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;">
<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 style="padding:10px 15px; text-align:right; font-weight:600; color:<?= $exp['amount'] > 0 ? '#10b981' : '#1e293b' ?>;">
<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 style="width:70px; padding-right:10px; text-align:right; white-space:nowrap;">
<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>
@@ -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.");
// On construit la date anniversaire avec le mois choisi par l'utilisateur
$anniversaryDate = "2000-" . str_pad((string)$resetMonth, 2, "0", STR_PAD_LEFT) . "-01";
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.");
// 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]);
$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>
+1005 -1091
View File
File diff suppressed because it is too large. Load diff
+1189 -254
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 {
// 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();
}
+447 -243
View File
@@ -16,11 +16,13 @@ $stmt = $pdo->prepare("
(COALESCE(h.budget_food, 0) + COALESCE(h.budget_extra, 0) + COALESCE((SELECT SUM(amount) FROM pf_holidays_items WHERE holiday_id = h.id), 0)) as total_cost,
(SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND is_paid = 1) as total_paid,
(SELECT COALESCE(SUM(amount), 0) FROM pf_savings WHERE holiday_id = h.id) as total_saved,
(SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND expense_context = 'transit') as total_transit
(SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND (expense_context = 'transit' OR (category = 'transport' AND (name LIKE '%Essence%' OR name LIKE '%Carburant%')))) as total_fuel,
(SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND category = 'transport' AND (name LIKE '%Péage%' OR name LIKE '%Peage%')) as total_tolls
FROM pf_holidays h
LEFT JOIN pf_vehicles v ON h.vehicle_id = v.id
WHERE h.id = ?
");
$stmt->execute([$id]);
$holiday = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -64,8 +66,9 @@ $mapPoints = array_values($steps);
// Affichage de la date
$dateDisplay = htmlspecialchars($holiday['period_hint'] ?? '');
if (empty($dateDisplay) && $holiday['start_date']) {
$dateDisplay = date('d/m/Y', strtotime($holiday['start_date']));
if ($holiday['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($holiday['end_date']));
// 💡 Format Jour/Mois uniquement
$dateDisplay = date('d/m', strtotime($holiday['start_date']));
if ($holiday['end_date']) $dateDisplay .= ' → ' . date('d/m', strtotime($holiday['end_date']));
}
$cost = (float)$holiday['total_cost'];
@@ -74,203 +77,152 @@ $saved = (float)$holiday['total_saved'];
$leftToPay = max(0, $cost - $paid);
$pctPaid = $cost > 0 ? min(100, ($paid / $cost) * 100) : 0;
$pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
// Image de fond (API Pixabay ou fallback par défaut)
$bgImage = !empty($holiday['image_url']) ? htmlspecialchars($holiday['image_url']) : '/modules/home/assets/img/background.jpg';
?>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
.mobile-only { display: none !important; }
@media (max-width: 768px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
</style>
<div class="pf-holidays-detail">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; flex-wrap: wrap; gap: 15px;">
<div style="display: flex; flex-direction: column; gap: 12px;">
<a href="?tab=list" class="pf-btn btn-secondary pf-btn-small" style="width: fit-content; text-decoration: none;"><?= tr('btn_back') ?></a>
<div style="display: flex; align-items: center; gap: 15px;">
<h1 style="margin: 0; font-size: 1.8rem;"><?= htmlspecialchars($holiday['title']) ?></h1>
<span class="hol-badge-status"><?= tr('hdl_status_' . $holiday['status']) ?></span>
</div>
</div>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<!-- Scripts JSON invisibles -->
<script id="holidayDataJson" type="application/json">
<?= json_encode(['main' => $holiday, 'items' => $generalItems], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>
</script>
<button type="button" class="pf-btn btn-primary pf-btn-small" onclick="generateTravelBook()" style="display: flex; align-items: center; gap: 6px;">
📖 Carnet de Voyage
</button>
<!-- 1. HERO HEADER IMMERSIF -->
<div class="hol-hero-banner">
<img src="<?= $bgImage ?>" class="hol-hero-bg-img" alt="">
<div class="hol-hero-overlay"></div>
<div class="hol-hero-content">
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="editHoliday(JSON.parse(document.getElementById('holidayDataJson').textContent))">
<?= tr('btn_edit_bases') ?>
</button>
<div class="hol-hero-top">
<a href="?tab=list" class="hol-hero-back-link">◀ <?= tr('btn_back') ?></a>
<span class="hol-hero-status-badge"><?= tr('hdl_status_' . $holiday['status']) ?></span>
</div>
<h1 class="hol-hero-title"><?= htmlspecialchars($holiday['title']) ?></h1>
<div class="hol-hero-date-badge">
🗓️ <?= $dateDisplay ?>
</div>
<div class="hol-summary-card">
<div class="hol-summary-grid">
<!-- LES PILLS (Indicateurs Route & Quotidien) -->
<div class="hol-hero-pills">
<?php if (!empty($holiday['vehicle_name'])): ?>
<div class="hol-summary-item">
<div class="hol-summary-label">Transport</div>
<div class="hol-summary-value">🚗 <?= htmlspecialchars($holiday['vehicle_name']) ?></div>
</div>
<!-- PILL ROUTE (Masqué par défaut, géré par OSRM) -->
<span id="block_total_distance" class="hol-pill-block hol-hidden">
<span class="hol-pill">
🛣️ <span id="global_total_distance">0</span> km (⏱️ <span id="global_total_duration">0h00</span>)
</span>
</span>
<!-- PILL PÉAGES -->
<span class="hol-pill">
💳 <?= number_format($holiday['total_tolls'], 0) ?> €
</span>
<!-- PILL ESSENCE (Avec Edit + Oeil) -->
<span class="hol-pill">
⛽ <span id="global_fuel_cost"><?= number_format($holiday['total_fuel'], 0) ?></span> €
<span onclick="updateFuelPrice()" class="hol-pill-action" title="Modifier le prix de l'essence">(<span id="display_fuel_price">1.85</span>€/L) ✏️</span>
<?php if ($holiday['total_fuel'] > 0 || $holiday['total_tolls'] > 0): ?>
<span onclick="openTransitModal()" class="hol-pill-action" title="Voir le détail des trajets">👁️</span>
<?php endif; ?>
<div class="hol-summary-item">
<div class="hol-summary-label">
Frais de route (Essence/Péages)
</div>
<div class="hol-summary-value" style="display:flex; align-items:center; gap:6px;">
<span style="font-size: 1.1rem;">⛽</span>
<strong><?= number_format($holiday['total_transit'], 0) ?> €</strong>
<span onclick="updateFuelPrice()" style="font-size: 0.75rem; color: var(--text-muted); cursor: pointer; transition: color 0.2s; display: inline-flex; align-items: center; gap: 3px;" onmouseover="this.style.color='var(--primary)';" onmouseout="this.style.color='var(--text-muted)';" title="Modifier le prix estimé du carburant">
(<span id="display_fuel_price">1.85</span> €/L) <span style="font-size:0.7rem; opacity:0.8;">✏️</span>
</span>
<?php if ($holiday['total_transit'] > 0): ?>
<span onclick="openTransitModal()" style="font-size: 1rem; cursor: pointer; opacity: 0.5; transition: opacity 0.2s; margin-left: 4px; display: inline-flex; align-items: center;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.5'" title="Voir le détail des trajets">
👁️
<!-- PILL BURGER (Food) -->
<span class="hol-pill">
🍔 <?= number_format($holiday['budget_food'], 0) ?> €
</span>
<?php endif; ?>
</div>
</div>
<div class="hol-summary-item" id="block_total_distance" style="display:none;">
<div class="hol-summary-label">Route & Temps de conduite</div>
<div class="hol-summary-value" style="display:flex; align-items:center; gap:6px;">
<span style="font-size: 1.1rem;">🛣️</span>
<strong><span id="global_total_distance">0</span> km</strong>
<span style="font-size: 0.85rem; color: var(--text-muted); margin-left: 5px;">
(⏱️ <span id="global_total_duration">0h00</span>)
<!-- PILL CADEAU (Extras) -->
<span class="hol-pill">
🎁 <?= number_format($holiday['budget_extra'], 0) ?> €
</span>
</div>
</div>
<div class="hol-summary-item">
<div class="hol-summary-label"><?= tr('hdl_label_budget_food_extras') ?></div>
<div class="hol-summary-value">🍔 <?= number_format($holiday['budget_food'], 0) ?> € | 🎁 <?= number_format($holiday['budget_extra'], 0) ?> €</div>
<!-- FINANCES GLOBALES -->
<div class="hol-hero-finances">
<div class="hol-finance-block">
<div class="hol-finance-label"><?= tr('hdl_total_budget') ?></div>
<div class="hol-finance-value"><?= number_format($cost, 0, ',', ' ') ?> €</div>
</div>
<div class="hol-summary-item">
<div class="hol-summary-label"><?= tr('hdl_total_budget') ?></div>
<div class="hol-summary-value total"><?= number_format($cost, 0, ',', ' ') ?> €</div>
<div class="hol-finance-block hol-finance-right">
<div class="hol-finance-label"><?= tr('hdl_left_to_pay') ?></div>
<div class="hol-finance-value hol-finance-value-due"><?= number_format($leftToPay, 0, ',', ' ') ?> €</div>
</div>
</div>
<!-- Barre de progression -->
<div class="hol-progress-bar">
<div class="hol-progress-paid" style="width:<?= $pctPaid ?>%;" title="<?= tr('hdl_paid') ?>"></div>
<div class="hol-progress-saved" style="width:<?= $pctSaved ?>%;" title="<?= tr('hdl_saved') ?>"></div>
<div class="hol-progress-paid" style="width: <?= $pctPaid ?>%;"></div>
<div class="hol-progress-saved" style="width: <?= $pctSaved ?>%;"></div>
</div>
<div class="hol-hero-progress-labels">
<span class="hol-label-paid-text">✓ <?= tr('hdl_paid') ?> : <?= number_format($paid, 0, ',', ' ') ?> €</span>
<span class="hol-label-saved-text">💼 <?= tr('hdl_saved') ?> : <?= number_format($saved, 0, ',', ' ') ?> €</span>
</div>
<div class="hol-progress-labels">
<span class="hol-label-paid">✓ <?= tr('hdl_paid') ?> : <?= number_format($paid, 0, ',', ' ') ?> €</span>
<span class="hol-label-saved">💼 <?= tr('hdl_saved') ?> : <?= number_format($saved, 0, ',', ' ') ?> €</span>
<span class="hol-label-left">⏳ <?= tr('hdl_left_to_pay') ?> : <?= number_format($leftToPay, 0, ',', ' ') ?> €</span>
</div>
</div>
<div class="hol-layout-grid">
<div class="hol-panel">
<div class="hol-panel-header" style="flex-wrap: wrap; gap: 10px;">
<div class="hol-panel-header-group">
<h3 style="margin:0;"><?= tr('hdl_map_itinerary') ?></h3>
<!-- 2. BARRE D'ACTIONS RAPIDES -->
<div class="hol-quick-actions-bar">
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="toggleTripMap()">
🗺️ Carte
</button>
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="openGlobalPlanningModal()">
📅 Planning
</button>
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="generateTravelBook()">
📖 PDF
</button>
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="editHoliday(JSON.parse(document.getElementById('holidayDataJson').textContent))">
⚙️ Modifier
</button>
</div>
<!-- 3. LA CARTE (Masquée par défaut) -->
<div id="tripMapWrapper" class="hol-map-wrapper hol-hidden">
<div class="hol-panel-header hol-map-panel-header">
<h3 class="hol-map-title">Itinéraire</h3>
<div class="hol-map-legend">
<span class="hol-legend-item" title="<?= tr('hdl_outbound') ?>"><span class="hol-legend-color aller"></span> <?= tr('hdl_outbound') ?></span>
<span class="hol-legend-item" title="<?= tr('hdl_return') ?>"><span class="hol-legend-color retour"></span> <?= tr('hdl_return') ?></span>
<span class="hol-legend-item"><span class="hol-legend-color aller"></span> Aller</span>
<span class="hol-legend-item"><span class="hol-legend-color retour"></span> Retour</span>
</div>
</div>
<button class="pf-btn pf-btn-small" onclick="openCheckpointModal('add')"><?= tr('hdl_btn_add_step') ?></button>
<!-- IMPORTANT : On garde l'ID tripMap pour que Leaflet (OSRM) fonctionne ! -->
<div id="tripMap" class="hol-map-canvas"></div>
</div>
<div id="tripMap" style="flex:1; width:100%; min-height: 400px; background:#f1f5f9; position: relative; z-index: 1;"></div> </div>
<div class="hol-panel">
<div class="hol-panel-header">
<h3 style="margin:0;"><?= tr('hdl_step_details') ?></h3>
<!-- 4. TIMELINE (Carnet de route Pleine Largeur) -->
<div class="hol-panel hol-panel-timeline">
<div class="hol-panel-header hol-timeline-header">
<h3><?= tr('hdl_step_details') ?></h3>
<button class="pf-btn pf-btn-small" onclick="openCheckpointModal('add')">+ <?= tr('hdl_btn_add_step') ?></button>
</div>
<div class="hol-panel-body">
<?php if (empty($steps)): ?>
<p style="color:var(--text-muted); font-style:italic; text-align:center; margin-top:40px;"><?= tr('hdl_no_steps') ?></p>
<p class="hol-empty-steps-text"><?= tr('hdl_no_steps') ?></p>
<?php else: ?>
<?php if (!empty($generalItems) || $holiday['budget_food'] > 0 || $holiday['budget_extra'] > 0): ?>
<div class="hol-checkpoint" style="border-left-color: #64748b; background: #f8fafc; margin-bottom: 20px;">
<div class="hol-cp-header">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.2rem;">🌍</span>
<strong style="color: #0f172a;"><?= tr('hdl_general_costs') ?></strong>
</div>
<?php
$generalTotal = $holiday['budget_food'] + $holiday['budget_extra'];
foreach ($generalItems as $gi) { $generalTotal += $gi['amount']; }
?>
<div style="display: flex; align-items: center; gap: 12px;">
<div style="font-size: 1.1rem; font-weight: 800; color: var(--primary); white-space: nowrap;"><?= number_format($generalTotal, 2, ',', ' ') ?> €</div>
<button onclick='editHoliday(JSON.parse(document.getElementById("holidayDataJson").textContent))' class="btn-icon-small" title="<?= tr('btn_edit') ?>">⚙️</button>
</div>
</div>
<div class="hol-cp-body">
<?php if ($holiday['budget_food'] > 0): ?>
<div class="hol-expense-wrapper"><div class="hol-expense-main">
<span class="hol-expense-name" style="color:#64748b;">🍔 <?= tr('hdl_food_bev') ?></span>
<span><strong class="hol-expense-amount"><?= number_format($holiday['budget_food'], 2, ',', ' ') ?> €</strong><span class="status-pending">⏳</span></span>
</div></div>
<?php endif; ?>
<?php if ($holiday['budget_extra'] > 0): ?>
<div class="hol-expense-wrapper"><div class="hol-expense-main">
<span class="hol-expense-name" style="color:#64748b;">🎁 <?= tr('hdl_extras') ?></span>
<span><strong class="hol-expense-amount"><?= number_format($holiday['budget_extra'], 2, ',', ' ') ?> €</strong><span class="status-pending">⏳</span></span>
</div></div>
<?php endif; ?>
<?php foreach ($generalItems as $it):
$icon = match($it['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
?>
<div class="hol-expense-wrapper">
<div class="hol-expense-main">
<span class="hol-expense-name">
<?= $icon ?> <?= htmlspecialchars($it['name']) ?>
</span>
<span class="hol-expense-price-group">
<strong class="hol-expense-amount"><?= number_format($it['amount'], 2, ',', ' ') ?> €</strong>
<span class="<?= $it['is_paid'] ? 'status-paid' : 'status-pending' ?>">
<?= $it['is_paid'] ? '✓' : '⏳' ?>
</span>
</span>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php foreach ($steps as $step): ?>
<div id="step-card-<?= $step['sort_order'] ?>" class="hol-checkpoint hol-checkpoint-draggable" draggable="true" data-location="<?= htmlspecialchars($step['location_name']) ?>">
<div class="hol-step-header">
<div class="hol-step-row-top">
<?php
$stepIcon = '📍'; $iconBg = 'transparent'; $iconColor = '#64748b';
$isReturn = ($holiday['return_step_id'] !== null && $holiday['return_step_id'] == $step['sort_order']);
$stepIcon = '📍';
if ($step['step_type'] === 'origin') {
$stepIcon = '🛫'; $iconBg = '#ecfdf5'; $iconColor = '#059669';
$stepIcon = '🛫';
} elseif ($step['step_type'] === 'destination') {
$stepIcon = '🛬'; $iconBg = '#fef2f2'; $iconColor = '#e11d48';
$stepIcon = '🛬';
}
$isReturn = ($holiday['return_step_id'] !== null && $holiday['return_step_id'] == $step['sort_order']);
?>
<div class="hol-step-icon" style="background: <?= $iconBg ?>; color: <?= $iconColor ?>;">
<div class="hol-step-icon hol-step-icon-<?= htmlspecialchars($step['step_type']) ?>">
<?= $stepIcon ?>
</div>
@@ -297,7 +249,7 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
<span class="hol-step-date">
<?= date('d/m', strtotime($step['step_start_date'])) ?> ➔ <?= date('d/m', strtotime($step['step_end_date'])) ?>
</span>
<div class="hol-weather-info" style="display:inline-flex;"></div>
<div class="hol-weather-info"></div>
<?php endif; ?>
</div>
@@ -305,15 +257,13 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
<span class="hol-step-price"><?= number_format($step['total_amount'], 2, ',', ' ') ?> €</span>
<button onclick="openGpsModal(<?= $step['lat'] ?>, <?= $step['lng'] ?>)"
class="btn-icon-small"
title="Y aller avec le GPS"
style="width:26px!important; height:26px!important; font-size:0.8rem; min-width:26px!important; min-height:26px!important;">
class="btn-icon-small hol-btn-action-icon"
title="Y aller avec le GPS">
🧭
</button>
<button onclick="openDocsModal(<?= $step['sort_order'] ?>)" class="btn-icon-small" title="Documents & Billets" style="width:26px!important; height:26px!important; font-size:0.8rem; min-width:26px!important; min-height:26px!important;">📎</button>
<button onclick="openDocsModal(<?= $step['sort_order'] ?>)" class="btn-icon-small hol-btn-action-icon" title="Documents & Billets">📎</button>
<button onclick='openPlanningModal(<?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="<?= tr('hdl_view_planning') ?>" style="width:26px!important; height:26px!important; font-size:0.8rem; min-width:26px!important; min-height:26px!important;">📅</button>
<button onclick='openCheckpointModal("edit", <?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="<?= tr('btn_edit') ?>" style="width:26px!important; height:26px!important; font-size:0.8rem; min-width:26px!important; min-height:26px!important;">✏️</button>
<button onclick='openCheckpointModal("edit", <?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small hol-btn-action-icon" title="<?= tr('btn_edit') ?>">✏️</button>
</div>
</div>
@@ -326,15 +276,32 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
if ($it['name'] === 'PF_TECHNICAL_POINT') continue;
$visibleItemsCount++;
$icon = match($it['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
// On vérifie si l'activité possède ses propres coordonnées GPS
$hasCustomGps = (!empty($it['lat']) && !empty($it['lng']) && ((float)$it['lat'] !== (float)$step['lat'] || (float)$it['lng'] !== (float)$step['lng']));
?>
<div class="hol-expense-wrapper">
<div class="hol-expense-main">
<span class="hol-expense-name"><?= $icon ?> <?= htmlspecialchars($it['name']) ?></span>
<span class="hol-expense-name">
<?= $icon ?>
<?php if ($hasCustomGps): ?>
<span onclick="openGpsModal(<?= $it['lat'] ?>, <?= $it['lng'] ?>)" class="hol-custom-gps-link" title="Y aller (GPS)">
<?= htmlspecialchars($it['name']) ?>
</span>
<?php else: ?>
<?= htmlspecialchars($it['name']) ?>
<?php endif; ?>
</span>
<span>
<strong class="hol-expense-amount"><?= number_format($it['amount'], 2, ',', ' ') ?> €</strong>
<span class="<?= $it['is_paid'] ? 'status-paid' : 'status-pending' ?>" title="<?= $it['is_paid'] ? tr('hdl_paid') : tr('hdl_to_pay') ?>"><?= $it['is_paid'] ? '✓' : '⏳' ?></span>
</span>
</div>
<?php if (!empty($it['notes'])): ?>
<div class="hol-expense-note">
<?= htmlspecialchars($it['notes']) ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
@@ -347,21 +314,20 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
<?php endif; ?>
</div>
</div>
<div class="pf-card hol-notes-card">
<h2 class="pf-card-title">📝 <?= tr('hdl_label_notes') ?></h2>
<div class="pf-card-body">
<textarea id="holidayGlobalNotes" class="pf-input hol-notes-textarea" rows="6" placeholder="<?= tr('hdl_ph_notes') ?>"><?= htmlspecialchars($holiday['notes'] ?? '') ?></textarea>
<div class="hol-notes-actions">
<button id="btnSaveHolidayNote" class="pf-btn" onclick="saveHolidayGlobalNote(<?= (int)$_GET['id'] ?>)">
💾 <?= tr('btn_save') ?>
</button>
</div>
</div>
</div>
<?php if (!empty($holiday['notes'])): ?>
<div class="hol-summary-card" style="margin-top: 24px; padding: 25px; border-left: 5px solid #f59e0b;">
<h3 style="margin: 0 0 15px 0; font-size: 1.2rem; color: #0f172a; display: flex; align-items: center; gap: 8px;">
📝 <?= tr('hdl_label_notes') ?>
</h3>
<div style="font-size: 0.95rem; color: #334155; white-space: pre-wrap; line-height: 1.6;">
<?= htmlspecialchars($holiday['notes']) ?>
</div>
</div>
<?php endif; ?>
</div>
<div id="checkpointModal" class="pf-modal">
@@ -371,6 +337,12 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
<button type="button" onclick="closeCheckpointModal()" class="pf-modal-close">&times;</button>
</div>
<!-- LES ONGLETS SONT ICI -->
<div style="display: flex; gap: 15px; border-bottom: 1px solid var(--border-light); margin-bottom: 20px;">
<button type="button" id="tabBtnInfo" onclick="switchCpTab('info')" style="background: none; border: none; font-weight: bold; font-size: 1rem; color: var(--primary); border-bottom: 2px solid var(--primary); padding-bottom: 8px; cursor: pointer; flex: 1;">📍 Infos de l'étape</button>
<button type="button" id="tabBtnProg" onclick="switchCpTab('prog')" style="background: none; border: none; font-weight: bold; font-size: 1rem; color: var(--text-muted); border-bottom: 2px solid transparent; padding-bottom: 8px; cursor: pointer; flex: 1;">🎟️ Programme & Frais</button>
</div>
<div id="cpSearchBlock" style="margin-bottom:20px;">
<?php if (!empty($favorites)): ?>
<div style="margin-bottom:15px; display:flex; gap:8px; flex-wrap:wrap;">
@@ -390,6 +362,7 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
<div id="searchResults" style="margin-top:10px; max-height:200px; overflow-y:auto;"></div>
</div>
<!-- FORMULAIRE (Le contenu apparaîtra ici) -->
<form action="/modules/holidays/includes/api/save_checkpoint.php" method="POST" id="formCheckpoint" style="display:none; border-top:1px solid #e2e8f0; padding-top:20px;">
<input type="hidden" name="holiday_id" value="<?= $id ?>">
<input type="hidden" name="old_location_name" id="cp_old_name">
@@ -397,6 +370,8 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
<input type="hidden" name="lng" id="cp_lng">
<input type="hidden" name="old_sort_order" id="cp_old_sort_order">
<!-- CONTENU DE L'ONGLET INFO -->
<div id="cpTabInfo">
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('hdl_label_step_name') ?></label>
<input type="text" name="location_name" id="cp_name" class="pf-input" style="font-weight:bold; color:var(--primary);" required>
@@ -429,13 +404,17 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
</label>
<p style="margin: 4px 0 0 24px; font-size: 0.75rem; color: #64748b;">La route sera tracée en orange à partir d'ici.</p>
</div>
</div>
<!-- CONTENU DE L'ONGLET PROGRAMME & FRAIS -->
<div id="cpTabProg" class="hol-hidden" style="display: none;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<label class="pf-label" style="margin:0;"><?= tr('hdl_planned_expenses') ?></label>
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="addCpExpenseLine()"><?= tr('hdl_btn_add_expense') ?></button>
</div>
<div id="cpExpensesContainer" style="margin-bottom:15px;"></div>
</div>
<div style="margin-bottom: 20px;">
<label style="display:flex; align-items:center; cursor:pointer; font-size:0.85rem; color:#475569;">
@@ -454,12 +433,15 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
</div>
<div id="planningModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 550px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 id="planningModalTitle" style="margin:0; color:var(--primary);">📅 <?= tr('hdl_planning_title') ?></h3>
<div class="pf-modal-content hol-modal-planning">
<div class="hol-modal-header">
<h3 id="planningModalTitle" class="hol-modal-title">📅 Planning Global</h3>
<button type="button" onclick="closePlanningModal()" class="pf-modal-close">&times;</button>
</div>
<div id="planningContainer" style="width: 100%;"></div>
<!-- Conteneur Injecté en JS -->
<div id="planningContainer" class="hol-planning-container"></div>
<div class="modal-footer">
<button type="button" onclick="closePlanningModal()" class="pf-btn btn-secondary"><?= tr('btn_close') ?></button>
</div>
@@ -467,41 +449,40 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
</div>
<div id="transitModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 500px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; border-bottom: 1px solid var(--border-light); padding-bottom: 10px;">
<h3 style="margin:0; color:var(--text-main);">🛣️ Détail des trajets</h3>
<div class="pf-modal-content hol-modal-sm">
<div class="hol-modal-header">
<h3 class="hol-modal-title">🛣️ Détail des trajets</h3>
<button type="button" onclick="closeTransitModal()" class="pf-modal-close">&times;</button>
</div>
<div id="transitDetailsContainer" style="max-height: 60vh; overflow-y: auto; padding-right: 5px;">
<p style="text-align:center; color:var(--text-muted); font-style:italic;">Calcul en cours...</p>
<div id="transitDetailsContainer" class="hol-transit-container">
<p class="hol-empty-text">Calcul en cours...</p>
</div>
<div class="modal-footer" style="padding-top:15px; border-top:1px solid var(--border-light); margin-top:15px;">
<div class="modal-footer">
<button type="button" onclick="closeTransitModal()" class="pf-btn btn-secondary"><?= tr('btn_close') ?></button>
</div>
</div>
</div>
<div id="gpsModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 320px; height: auto !important; min-height: 0 !important; text-align: center; padding: 20px; margin: auto;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 style="margin:0; font-size: 1.2rem; color: var(--text-main);">🧭 Y aller avec...</h3>
<div class="pf-modal-content hol-modal-gps">
<div class="hol-modal-header">
<h3 class="hol-modal-title">🧭 Y aller avec...</h3>
<button type="button" onclick="closeGpsModal()" class="pf-modal-close">×</button>
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
<button onclick="launchGpsApp('waze')" class="pf-btn btn-secondary" style="font-size: 1.1rem; padding: 12px; display:flex; align-items:center; justify-content:center; gap:10px;">
<img src="https://cdn.simpleicons.org/waze/05C8F0" alt="Waze" style="width: 24px; height: 24px;">
<div class="hol-gps-buttons">
<button onclick="launchGpsApp('waze')" class="pf-btn btn-secondary hol-gps-btn">
<img src="https://cdn.simpleicons.org/waze/05C8F0" alt="Waze" class="hol-gps-icon">
Waze
</button>
<button onclick="launchGpsApp('gmaps')" class="pf-btn btn-secondary" style="font-size: 1.1rem; padding: 12px; display:flex; align-items:center; justify-content:center; gap:10px;">
<img src="https://cdn.simpleicons.org/googlemaps" alt="Google Maps" style="width: 24px; height: 24px;">
<button onclick="launchGpsApp('gmaps')" class="pf-btn btn-secondary hol-gps-btn">
<img src="https://cdn.simpleicons.org/googlemaps" alt="Google Maps" class="hol-gps-icon">
Google Maps
</button>
<button onclick="launchGpsApp('amaps')" class="pf-btn btn-secondary" style="font-size: 1.1rem; padding: 12px; display:flex; align-items:center; justify-content:center; gap:10px;">
<img src="https://cdn.simpleicons.org/apple/000000" alt="Apple Maps" style="width: 24px; height: 24px;">
<button onclick="launchGpsApp('amaps')" class="pf-btn btn-secondary hol-gps-btn">
<img src="https://cdn.simpleicons.org/apple/000000" alt="Apple Maps" class="hol-gps-icon">
Apple Maps
</button>
</div>
@@ -509,31 +490,35 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
</div>
<div id="docsModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 450px; padding: 20px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; border-bottom: 1px solid var(--border-light); padding-bottom: 10px;">
<h3 style="margin:0; font-size: 1.2rem; color: var(--text-main);">📎 Porte-documents</h3>
<div class="pf-modal-content hol-modal-docs">
<div class="hol-modal-header">
<h3 class="hol-modal-title">📎 Porte-documents</h3>
<button type="button" onclick="closeDocsModal()" class="pf-modal-close">×</button>
</div>
<div style="text-align: center; padding: 20px; background: var(--bg-page); border: 2px dashed var(--border-light); border-radius: 8px; margin-bottom: 15px;">
<p style="margin-top:0; color: var(--text-muted); font-size: 0.9rem;">Ajoutez vos billets, réservations ou PDFs pour cette étape.</p>
<div class="hol-docs-dropzone">
<p class="hol-docs-hint">Ajoutez vos billets, réservations ou PDFs pour cette étape.</p>
<input type="file" id="docFileInput" style="display: none;" accept=".pdf,.png,.jpg,.jpeg" onchange="handleFileUpload(this)">
<input type="file" id="docFileInput" class="hol-hidden" accept=".pdf,.png,.jpg,.jpeg" onchange="handleFileUpload(this)">
<button type="button" class="pf-btn btn-primary" onclick="document.getElementById('docFileInput').click()" style="margin: 10px 0;">
<button type="button" class="pf-btn btn-primary hol-docs-select-btn" onclick="document.getElementById('docFileInput').click()">
+ Sélectionner un fichier
</button>
<div id="uploadStatus" style="font-size: 0.85rem; font-weight: bold; margin-top: 10px;"></div>
<div id="uploadStatus" class="hol-upload-status"></div>
</div>
<div id="docsListContainer" style="display: flex; flex-direction: column; gap: 8px;">
<p style="text-align: center; font-size: 0.85rem; color: var(--text-muted); font-style: italic;">Aucun document pour cette étape.</p>
<div id="docsListContainer" class="hol-docs-list">
<p class="hol-empty-text">Aucun document pour cette étape.</p>
</div>
</div>
</div>
<?php include __DIR__ . '/modal.php'; ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<?php include __DIR__ . '/pdf_template.php'; ?>
<script>
// --- 1. SÉCURISATION TRADUCTIONS ET VARIABLES ---
window.MAP_POINTS = <?= json_encode($mapPoints ?? []) ?>;
@@ -541,62 +526,281 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
window.I18N = {
...(window.I18N || {}),
'hdl_js_search_loading': "<?= tr('hdl_js_search_loading') ?>",
'hdl_js_no_result': "<?= tr('hdl_js_no_result') ?>",
'hdl_js_confirm_del_trip': "<?= tr('hdl_js_confirm_del_trip') ?>",
'hdl_js_confirm_del_step': "<?= tr('hdl_js_confirm_del_step') ?>",
'hdl_js_step_label': "<?= tr('hdl_js_step_label') ?>",
'hdl_js_ph_expense_name': "<?= tr('hdl_js_ph_expense_name') ?>",
'hdl_js_delete_line': "<?= tr('btn_delete') ?>",
'hdl_planning_title': "<?= tr('hdl_planning_title') ?>",
'hdl_to_place': "<?= tr('hdl_to_place') ?>",
'hdl_js_missing_dates_title': "<?= tr('hdl_js_missing_dates_title') ?>",
'hdl_js_missing_dates_msg': "<?= tr('hdl_js_missing_dates_msg') ?>",
'hdl_modal_title': "<?= tr('hdl_modal_title') ?>",
'hdl_js_edit_step': "<?= tr('hdl_js_edit_step') ?>",
'hdl_ph_notes': "<?= tr('hdl_ph_notes') ?>",
'hdl_btn_add_step': "<?= tr('hdl_btn_add_step') ?>",
'hdl_quick_edit_title': "<?= tr('hdl_quick_edit_title') ?>",
'hdl_paid': "<?= tr('hdl_paid') ?>",
// --- NOUVELLES CLÉS MÉTÉO ICI ---
'weather_sunny': "<?= tr('weather_sunny') ?>",
'weather_cloudy': "<?= tr('weather_cloudy') ?>",
'weather_rainy': "<?= tr('weather_rainy') ?>",
'weather_snowy': "<?= tr('weather_snowy') ?>",
'weather_forecast': "<?= tr('weather_forecast') ?>",
'weather_historical': "<?= tr('weather_historical') ?>"
'hdl_js_search_loading': <?= json_encode(tr('hdl_js_search_loading')) ?>,
'hdl_js_no_result': <?= json_encode(tr('hdl_js_no_result')) ?>,
'hdl_js_confirm_del_trip': <?= json_encode(tr('hdl_js_confirm_del_trip')) ?>,
'hdl_js_confirm_del_step': <?= json_encode(tr('hdl_js_confirm_del_step')) ?>,
'hdl_js_step_label': <?= json_encode(tr('hdl_js_step_label')) ?>,
'hdl_js_ph_expense_name': <?= json_encode(tr('hdl_js_ph_expense_name')) ?>,
'hdl_js_delete_line': <?= json_encode(tr('btn_delete')) ?>,
'hdl_planning_title': <?= json_encode(tr('hdl_planning_title')) ?>,
'hdl_to_place': <?= json_encode(tr('hdl_to_place')) ?>,
'hdl_js_missing_dates_title': <?= json_encode(tr('hdl_js_missing_dates_title')) ?>,
'hdl_js_missing_dates_msg': <?= json_encode(tr('hdl_js_missing_dates_msg')) ?>,
'hdl_modal_title': <?= json_encode(tr('hdl_modal_title')) ?>,
'hdl_js_edit_step': <?= json_encode(tr('hdl_js_edit_step')) ?>,
'hdl_ph_notes': <?= json_encode(tr('hdl_ph_notes')) ?>,
'hdl_btn_add_step': <?= json_encode(tr('hdl_btn_add_step')) ?>,
'hdl_quick_edit_title': <?= json_encode(tr('hdl_quick_edit_title')) ?>,
'hdl_paid': <?= json_encode(tr('hdl_paid')) ?>,
'weather_sunny': <?= json_encode(tr('weather_sunny')) ?>,
'weather_cloudy': <?= json_encode(tr('weather_cloudy')) ?>,
'weather_rainy': <?= json_encode(tr('weather_rainy')) ?>,
'weather_snowy': <?= json_encode(tr('weather_snowy')) ?>,
'weather_forecast': <?= json_encode(tr('weather_forecast')) ?>,
'weather_historical': <?= json_encode(tr('weather_historical')) ?>
};
// Fallback de sécurité pour s'assurer que les modales peuvent toujours se fermer
window.closeCheckpointModal = window.closeCheckpointModal || function() {
const savedFuelPrice = localStorage.getItem('holidays_fuel_price') || 1.85;
window.FUEL_PRICE = parseFloat(savedFuelPrice);
const displayFuelEl = document.getElementById('display_fuel_price');
if (displayFuelEl) displayFuelEl.innerText = window.FUEL_PRICE.toFixed(2);
window.VEHICLE_CONSUMPTION = <?= !empty($holiday['vehicle_consumption']) ? (float)$holiday['vehicle_consumption'] : 7 ?>;
window.GLOBAL_RETURN_STEP_ID = <?= $holiday['return_step_id'] !== null ? $holiday['return_step_id'] : 'null' ?>;
// --- 2. SURCHARGE SÉCURISÉE DES FONCTIONS JS (APRÈS CHARGEMENT DE HOLIDAYS.JS) ---
document.addEventListener('DOMContentLoaded', function() {
// 1. Correction du conflit d'onglet
window.switchCpTab = function(tabId) {
const btnInfo = document.getElementById('tabBtnInfo');
const btnProg = document.getElementById('tabBtnProg');
const tabInfo = document.getElementById('cpTabInfo');
const tabProg = document.getElementById('cpTabProg');
if (tabId === 'info') {
if (btnInfo) { btnInfo.style.borderBottomColor = "var(--primary)"; btnInfo.style.color = "var(--primary)"; }
if (btnProg) { btnProg.style.borderBottomColor = "transparent"; btnProg.style.color = "var(--text-muted)"; }
if (tabInfo) { tabInfo.style.display = "block"; tabInfo.classList.remove("hol-hidden"); }
if (tabProg) { tabProg.style.display = "none"; tabProg.classList.add("hol-hidden"); }
} else {
if (btnProg) { btnProg.style.borderBottomColor = "var(--primary)"; btnProg.style.color = "var(--primary)"; }
if (btnInfo) { btnInfo.style.borderBottomColor = "transparent"; btnInfo.style.color = "var(--text-muted)"; }
if (tabProg) { tabProg.style.display = "block"; tabProg.classList.remove("hol-hidden"); }
if (tabInfo) { tabInfo.style.display = "none"; tabInfo.classList.add("hol-hidden"); }
}
};
// 2. Correction modale : ajout de l'injection lat/lng aux items
window.openCheckpointModal = function(mode, data = null) {
const searchBlock = document.getElementById("cpSearchBlock");
const formBlock = document.getElementById("formCheckpoint");
const container = document.getElementById("cpExpensesContainer");
const btnDel = document.getElementById("btnDeleteCp");
const modalTitle = document.getElementById("cpModalTitle");
if(container) container.innerHTML = "";
if (document.getElementById("cp_start_date")) document.getElementById("cp_start_date").value = "";
if (document.getElementById("cp_end_date")) document.getElementById("cp_end_date").value = "";
if (document.getElementById("searchPlaceInput")) document.getElementById("searchPlaceInput").value = "";
if (document.getElementById("searchResults")) document.getElementById("searchResults").innerHTML = "";
window.switchCpTab('info');
if (mode === "add") {
if (modalTitle) modalTitle.innerText = "📍 " + (window.I18N['hdl_btn_add_step'] || "Ajouter une étape");
if (searchBlock) searchBlock.style.display = "block";
if (formBlock) formBlock.style.display = "block";
if (btnDel) btnDel.style.display = "none";
if (document.getElementById("cp_old_sort_order")) document.getElementById("cp_old_sort_order").value = "";
if (document.getElementById("cp_name")) document.getElementById("cp_name").value = "";
if (document.getElementById("cp_step_type")) {
document.getElementById("cp_step_type").value = "stop";
if (typeof toggleStepDates === 'function') toggleStepDates("stop");
}
if (document.getElementById("cp_set_as_return")) document.getElementById("cp_set_as_return").checked = false;
if (typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
} else if (mode === "edit" && data) {
if (modalTitle) modalTitle.innerText = "✏️ " + (window.I18N['hdl_js_edit_step'] || "Modifier l'étape");
if (searchBlock) searchBlock.style.display = "none";
if (formBlock) formBlock.style.display = "block";
if (btnDel) btnDel.style.display = "block";
if (document.getElementById("cp_lat")) document.getElementById("cp_lat").value = data.lat;
if (document.getElementById("cp_lng")) document.getElementById("cp_lng").value = data.lng;
if (document.getElementById("cp_old_sort_order")) document.getElementById("cp_old_sort_order").value = data.sort_order;
if (document.getElementById("cp_name")) document.getElementById("cp_name").value = data.location_name;
if (document.getElementById("cp_start_date")) document.getElementById("cp_start_date").value = data.step_start_date || "";
if (document.getElementById("cp_end_date")) document.getElementById("cp_end_date").value = data.step_end_date || "";
if (document.getElementById("cp_step_type")) {
const type = data.step_type || "stop";
document.getElementById("cp_step_type").value = type;
if (typeof toggleStepDates === 'function') toggleStepDates(type);
}
if (document.getElementById("cp_set_as_return")) {
document.getElementById("cp_set_as_return").checked = (window.GLOBAL_RETURN_STEP_ID == data.sort_order);
}
// Génération avec transmission de la latitude et longitude de chaque dépense
if (data.items && data.items.length > 0) {
let visibleCount = 0;
data.items.forEach((it) => {
if (it.name !== "PF_TECHNICAL_POINT") {
if (typeof window.addCpExpenseLine === 'function') {
window.addCpExpenseLine(
it.category, it.name, it.amount, it.is_paid, it.notes || "",
it.id || "", it.item_date || "", it.item_time || "", it.duration || 1,
it.expense_context || "local", it.lat || "", it.lng || ""
);
}
visibleCount++;
}
});
if (visibleCount === 0 && typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
} else {
if (typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
}
}
try {
if (typeof cpDateRangePicker !== 'undefined' && cpDateRangePicker && typeof cpDateRangePicker.destroy === 'function') {
cpDateRangePicker.destroy();
}
} catch (e) {}
const modal = document.getElementById("checkpointModal");
if (modal) {
modal.style.display = "flex";
document.body.classList.add("no-scroll");
}
};
// 3. LA FONCTION AVEC LA CLASSE CSS PARFAITE ET LE BOUTON 📍
window.addCpExpenseLine = function(category = "accommodation", name = "", amount = "", isPaid = 0, notes = "", itemId = "", itemDate = "", itemTime = "", itemDur = 1, expenseContext = "local", itemLat = "", itemLng = "") {
const container = document.getElementById("cpExpensesContainer");
if (!container) return;
const div = document.createElement("div");
div.className = "hol-form-row"; // Force l'utilisation du CSS
const isChecked = isPaid == 1 ? "checked" : "";
// Indicateur visuel (bouton vert) si on a déjà des coordonnées pour cette activité
const pinBtnStyle = (itemLat && itemLng) ? "background:var(--success);color:white;border-color:var(--success);" : "";
div.innerHTML = `
<div class="hol-form-inner">
<select name="items[cat][]" class="pf-input hol-form-select">
<option value="accommodation" ${category === "accommodation" ? "selected" : ""}>🏨</option>
<option value="transport" ${category === "transport" ? "selected" : ""}>🚗</option>
<option value="activity" ${category === "activity" ? "selected" : ""}>🎫</option>
</select>
<input type="hidden" name="items[context][]" value="${expenseContext}">
<input type="text" name="items[name][]" class="pf-input hol-form-text" placeholder="${window.I18N['hdl_js_ph_expense_name'] || 'Nom de la dépense'}" value="${name}" required>
<button type="button" class="btn-icon-action" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border-light);border-radius:6px; margin:0; flex-shrink:0; ${pinBtnStyle}" onclick="window.toggleItemSearch(this)" title="Adresse précise">📍</button>
<input type="number" step="0.01" name="items[amount][]" class="pf-input hol-form-number" placeholder="0.00" value="${amount}" required>
<label class="hol-form-paid-label" title="${window.I18N['hdl_paid'] || 'Payé'}">
<input type="checkbox" ${isChecked} onchange="this.nextElementSibling.value = this.checked ? 1 : 0" style="width:16px;height:16px;margin:0;accent-color:var(--primary);">
<input type="hidden" name="items[paid][]" value="${isPaid}">
<span class="hol-form-paid-text">Payé</span>
</label>
<button type="button" onclick="this.parentElement.parentElement.remove()" title="${window.I18N['hdl_js_delete_line'] || 'Supprimer'}" class="btn-icon-action delete btn-remove-expense" style="margin: 0;">🗑️</button>
</div>
<div class="hol-item-search-box" style="display:none; padding: 10px; background: var(--bg-subtle); border-radius: 8px; margin-top: 6px; width: 100%;">
<div style="display:flex; gap:10px;">
<input type="text" class="pf-input item-search-input" placeholder="Chercher une adresse..." onkeypress="if(event.key === 'Enter') { window.searchItemPlace(this); return false; }">
<button type="button" class="pf-btn btn-secondary" onclick="window.searchItemPlace(this)">🔍</button>
</div>
<div class="item-search-results" style="margin-top:10px; max-height:150px; overflow-y:auto;"></div>
</div>
<div class="hol-form-subrow" style="display: flex; padding-left: 58px; margin-top: 6px;">
<input type="text" name="items[notes][]" class="pf-input hol-form-notes-input" placeholder="${window.I18N['hdl_ph_notes'] || 'Notes...'}" value="${notes}" style="font-size: 0.8rem; padding: 6px 8px; border-style: dashed; color: var(--text-muted); width: 100%; background: transparent; margin: 0;">
</div>
<input type="hidden" name="items[id][]" value="${itemId}">
<input type="hidden" name="items[date][]" value="${itemDate}">
<input type="hidden" name="items[time][]" value="${itemTime}">
<input type="hidden" name="items[duration][]" value="${itemDur}">
<input type="hidden" name="items[lat][]" value="${itemLat}">
<input type="hidden" name="items[lng][]" value="${itemLng}">
`;
container.appendChild(div);
};
// 4. RÉINTÉGRATION DES FONCTIONS DE GÉOCODAGE DANS LE MÊME SCOPE
window.toggleItemSearch = function(btn) {
const searchBox = btn.closest(".hol-form-row").querySelector(".hol-item-search-box");
searchBox.style.display = searchBox.style.display === "none" ? "block" : "none";
};
window.searchItemPlace = async function(btn) {
const container = btn.closest(".hol-item-search-box");
const input = container.querySelector(".item-search-input");
const resultsDiv = container.querySelector(".item-search-results");
const q = input.value.trim();
if (q.length < 3) return;
resultsDiv.innerHTML = `<span style="color:#64748b; font-size:0.85rem;">Recherche en cours...</span>`;
try {
const data = await pachaFetch("/modules/holidays/includes/api/geocode.php?limit=5&q=" + encodeURIComponent(q), { method: 'GET' });
resultsDiv.innerHTML = "";
if (data.error || !data.results || data.results.length === 0) {
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">Aucun résultat</span>`;
return;
}
data.results.forEach((place) => {
const b = document.createElement("button");
b.type = "button";
b.className = "pf-btn btn-secondary";
b.style.textAlign = "left";
b.style.padding = "8px";
b.style.marginBottom = "4px";
b.style.width = "100%";
b.innerText = "📍 " + place.display_name;
b.onclick = () => window.selectItemPlace(container, place.lat, place.lng, place.display_name);
resultsDiv.appendChild(b);
});
} catch (err) {
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">Erreur de connexion</span>`;
}
};
window.selectItemPlace = function(container, lat, lng, displayName) {
const row = container.closest(".hol-form-row");
row.querySelector('input[name="items[lat][]"]').value = lat;
row.querySelector('input[name="items[lng][]"]').value = lng;
// Si le champ note est vide, on l'utilise pour y placer le nom complet de l'adresse
const notesInput = row.querySelector('input[name="items[notes][]"]');
if (notesInput.value === "") {
notesInput.value = displayName;
}
container.style.display = "none";
// Le bouton devient vert pour confirmer la présence de coordonnées
const pinBtn = row.querySelector('.btn-icon-action[title="Adresse précise"]');
if (pinBtn) {
pinBtn.style.background = "var(--success)";
pinBtn.style.color = "white";
pinBtn.style.borderColor = "var(--success)";
}
};
window.closeCheckpointModal = function() {
const modal = document.getElementById('checkpointModal');
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll');
};
window.closePlanningModal = window.closePlanningModal || function() {
window.closePlanningModal = function() {
const modal = document.getElementById('planningModal');
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll');
};
// 1. On charge le prix depuis le navigateur (ou 1.85 par défaut)
const savedFuelPrice = localStorage.getItem('holidays_fuel_price') || 1.85;
window.FUEL_PRICE = parseFloat(savedFuelPrice);
// 2. On met à jour le texte du petit bouton "✏️" en haut de la page
const displayFuelEl = document.getElementById('display_fuel_price');
if (displayFuelEl) displayFuelEl.innerText = window.FUEL_PRICE.toFixed(2);
// 3. Variables voiture et retour
window.VEHICLE_CONSUMPTION = <?= !empty($holiday['vehicle_consumption']) ? (float)$holiday['vehicle_consumption'] : 7 ?>;
window.GLOBAL_RETURN_STEP_ID = <?= $holiday['return_step_id'] !== null ? $holiday['return_step_id'] : 'null' ?>;
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<?php include __DIR__ . '/pdf_template.php'; ?>
<script src="/modules/holidays/holidays.js?v=<?= time() ?>"></script>
+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>`;
}
+5 -15
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';
// --- 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
+254 -103
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;">
<?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>
<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="Supprimer" style="background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px;" onclick="return confirm('Supprimer définitivement ce profil ?')">🗑️</button>
<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'; ?>