Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b450a345f6 | ||
|
|
1997624065 | ||
|
|
71f20da637 | ||
|
|
7aeb0ed016 | ||
|
|
7c25513336 | ||
|
|
165a0b371c | ||
|
|
815266383b | ||
|
|
c68e5f8599 | ||
|
|
bd9ab7304e | ||
|
|
4e156a09f7 | ||
|
|
bfa70402a9 | ||
|
|
3a64934eb5 | ||
|
|
4201adb571 | ||
|
|
a7a8244a33 | ||
|
|
693b277002 | ||
|
|
a69a8950b3 | ||
|
|
63e0cf0ec2 | ||
|
|
c3eae9e1d4 | ||
|
|
4cfef07dc6 | ||
|
|
3c615e3021 | ||
|
|
094a5b2b91 | ||
|
|
dc8a2e5244 | ||
|
|
8a7b5b6644 | ||
|
|
a2e0904132 | ||
|
|
cfe8962bd4 | ||
|
|
42ccb89b66 | ||
|
|
21c9b37ae0 | ||
|
|
c05802fc79 | ||
|
|
348a8f1811 | ||
|
|
28a98f5790 | ||
|
|
32c291ff2c | ||
|
|
b3139b577a | ||
|
|
f19a7442de | ||
|
|
536f479423 | ||
|
|
685c649e7c | ||
|
|
074d8e12d2 | ||
|
|
f748330a1b | ||
|
|
ebdbee71db | ||
|
|
659b286967 | ||
|
|
ac8ebf7328 | ||
|
|
49d4ec7c35 | ||
|
|
c48a6ba116 | ||
|
|
20d6c9b7b0 | ||
|
|
d856d0a409 | ||
|
|
86ef3ccbf2 | ||
|
|
3e98de9c18 |
No files matched your search
+28
-3
@@ -16,8 +16,23 @@ $hasBudgetItems = ((int)$pdo->query("SELECT COUNT(*) FROM pf_budget_items")->fet
|
||||
// Le minimum vital pour que ça tourne
|
||||
$isBudgetSetupOk = ($hasCategories && $hasSalaries && $hasAccounts);
|
||||
|
||||
// Gestion de l'onglet actif (par défaut 'suivi')
|
||||
// ============================================================================
|
||||
// GESTION DES VUES (MANDATORY VS OPTIONAL)
|
||||
// ============================================================================
|
||||
$enabledViews = $_SESSION['enabled_views'] ?? ['budget_prev', 'budget_epargne', 'budget_provisions'];
|
||||
|
||||
// Vues obligatoires pour le cœur du budget
|
||||
$allowedTabs = ['suivi', 'recap'];
|
||||
|
||||
if (in_array('budget_prev', $enabledViews)) $allowedTabs[] = 'budget_prev';
|
||||
if (in_array('budget_epargne', $enabledViews)) $allowedTabs[] = 'epargne';
|
||||
if (in_array('budget_provisions', $enabledViews)) $allowedTabs[] = 'provisions';
|
||||
|
||||
// Gestion de l'onglet actif avec fallback de sécurité
|
||||
$tab = $_GET['tab'] ?? 'suivi';
|
||||
if (!in_array($tab, $allowedTabs)) {
|
||||
$tab = 'suivi';
|
||||
}
|
||||
|
||||
$pageTitle = tr('budget_page_title');
|
||||
$activePage = "budget";
|
||||
@@ -30,7 +45,6 @@ require __DIR__ . '/header.php';
|
||||
<div class="pf-hero budget-hero">
|
||||
<div class="budget-title-group">
|
||||
<h1><?= tr('budget_main_header') ?></h1>
|
||||
<!-- Bouton avec séparation claire entre l'engrenage animable et l'alerte fixe -->
|
||||
<button class="btn-header-settings" onclick="openBudgetSettings()" title="<?= tr('settings') ?>">
|
||||
<span class="gear-icon-anim">⚙️</span>
|
||||
<?php if (!$isBudgetSetupOk || !$hasBudgetItems): ?>
|
||||
@@ -45,26 +59,37 @@ require __DIR__ . '/header.php';
|
||||
<span><?= tr('budget_tab_tracking') ?></span>
|
||||
</a>
|
||||
|
||||
<?php if (in_array('budget_prev', $allowedTabs)): ?>
|
||||
<a href="?tab=budget_prev" class="tab-item <?= $tab == 'budget_prev' ? 'active' : '' ?>">
|
||||
<span class="tab-icon">🎯</span>
|
||||
<span><?= tr('budget_tab_prev') ?></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (in_array('epargne', $allowedTabs)): ?>
|
||||
<a href="?tab=epargne" class="tab-item <?= $tab == 'epargne' ? 'active' : '' ?>">
|
||||
<span class="tab-icon">🐷</span>
|
||||
<span><?= tr('budget_tab_savings') ?></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<a href="?tab=recap" class="tab-item <?= $tab == 'recap' ? 'active' : '' ?>">
|
||||
<span class="tab-icon">📊</span>
|
||||
<span><?= tr('budget_tab_recap') ?></span>
|
||||
</a>
|
||||
|
||||
<?php if (in_array('provisions', $allowedTabs)): ?>
|
||||
<a href="?tab=provisions" class="tab-item <?= $tab == 'provisions' ? 'active' : '' ?>">
|
||||
<span class="tab-icon">🧮</span>
|
||||
<span><?= tr('budget_tab_provisions') ?></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</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";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"require": {
|
||||
"smalot/pdfparser": "^2.12"
|
||||
}
|
||||
}
|
||||
Generated
+155
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "f7c071b38f69a0b82ff94c1527c44426",
|
||||
"packages": [
|
||||
{
|
||||
"name": "smalot/pdfparser",
|
||||
"version": "v2.12.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/smalot/pdfparser.git",
|
||||
"reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/smalot/pdfparser/zipball/2cfa0d92bd557875c9f52a75fde0e8392302a354",
|
||||
"reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"ext-zlib": "*",
|
||||
"php": ">=7.1",
|
||||
"symfony/polyfill-mbstring": "^1.18"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"Smalot\\PdfParser\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastien MALOT",
|
||||
"email": "sebastien@malot.fr"
|
||||
}
|
||||
],
|
||||
"description": "Pdf parser library. Can read and extract information from pdf file.",
|
||||
"homepage": "https://www.pdfparser.org",
|
||||
"keywords": [
|
||||
"extract",
|
||||
"parse",
|
||||
"parser",
|
||||
"pdf",
|
||||
"text"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/smalot/pdfparser/issues",
|
||||
"source": "https://github.com/smalot/pdfparser/tree/v2.12.5"
|
||||
},
|
||||
"time": "2026-04-17T11:37:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.38.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"provide": {
|
||||
"ext-mbstring": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Mbstring\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for the Mbstring extension",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"mbstring",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-27T06:59:30+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
+208
-113
@@ -1,127 +1,222 @@
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
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;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pf-module-card-wrapper:has(input.js-mod-checkbox:checked) {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
[data-theme="dark"] .pf-sub-module-pill {
|
||||
background: var(--bg-page);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
[data-theme="dark"] .pf-sub-module-pill:has(input:checked) {
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS families (
|
||||
invite_code VARCHAR(32) NOT NULL UNIQUE,
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
enabled_modules JSON DEFAULT NULL,
|
||||
enabled_views JSON DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -22,6 +23,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -644,4 +662,15 @@ CREATE TABLE IF NOT EXISTS pf_planka_config (
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
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;
|
||||
+93
-26
@@ -85,7 +85,7 @@ $pageCss = "/modules/family-calendar/family-calendar.css";
|
||||
require __DIR__ . '/header.php';
|
||||
?>
|
||||
|
||||
<div class="pf-container" style="max-width:100%; padding:0;">
|
||||
<div class="pf-container" style="--p1-main: <?= $parents[0]['color'] ?? '#0891b2' ?>; --p2-main: <?= $parents[1]['color'] ?? '#f59e0b' ?>; max-width:100%; padding:0;">
|
||||
<div class="fc-header-row" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; margin-bottom: 24px;">
|
||||
|
||||
<!-- Bloc Titre + Roue crantée -->
|
||||
@@ -185,18 +185,33 @@ require __DIR__ . '/header.php';
|
||||
<div class="pf-form-group">
|
||||
<label class="pf-label"><?= tr('fc_label_remaining_balance') ?></label>
|
||||
<div style="display:flex; align-items:center;">
|
||||
<input type="number" step="0.5" id="snapBalance" class="pf-input" placeholder="<?= tr('fc_placeholder_balance') ?>" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--pf-primary);" required>
|
||||
<!-- 🔥 STEP 0.01 POUR DEUX DÉCIMALES STRICTES -->
|
||||
<input type="number" step="0.01" id="snapBalance" class="pf-input" placeholder="<?= tr('fc_placeholder_balance') ?>" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--pf-primary);" required>
|
||||
<span style="background: var(--pf-bg-lighter); border: 1px solid var(--pf-border); padding: 0 12px; height: 42px; display: flex; align-items: center; border-radius: 0 8px 8px 0; color: var(--pf-text-muted); font-size: 0.9rem; font-weight: 600;">
|
||||
<?= tr('fc_unit_days') ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- NOUVEAU BLOC : Solde reporté -->
|
||||
<div class="pf-form-group" style="margin-top: 15px;">
|
||||
<label class="pf-label"><?= tr('fc_label_carry_over') ?></label>
|
||||
<div style="display:flex; align-items:center;">
|
||||
<!-- 🔥 STEP 0.01 POUR DEUX DÉCIMALES STRICTES -->
|
||||
<input type="number" step="0.01" id="snapCarryOver" class="pf-input" placeholder="ex: 2.00" value="0" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--danger);" required>
|
||||
<span style="background: var(--pf-bg-lighter); border: 1px solid var(--pf-border); padding: 0 12px; height: 42px; display: flex; align-items: center; border-radius: 0 8px 8px 0; color: var(--pf-text-muted); font-size: 0.9rem; font-weight: 600;">
|
||||
<?= tr('fc_unit_days') ?>
|
||||
</span>
|
||||
</div>
|
||||
<small class="pf-muted-note"><?= tr('fc_carry_over_note') ?></small>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER INTÉGRÉ DANS LE FORMULAIRE POUR ÉVITER LE BUG DE CIBLAGE -->
|
||||
<div class="pf-modal-footer" style="margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--pf-border); display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" class="pf-btn pf-btn-secondary" onclick="document.getElementById('modalSnapshot').style.display = 'none'; document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button>
|
||||
<button type="submit" id="btnSubmitSnapshot" class="pf-btn pf-btn-primary"><?= tr('fc_btn_save_snap') ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="pf-modal-footer">
|
||||
<button type="button" class="pf-btn pf-btn-secondary" onclick="document.getElementById('modalSnapshot').classList.remove('open'); document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button>
|
||||
<button type="submit" form="formSnapshot" class="pf-btn pf-btn-primary"><?= tr('fc_btn_save_snap') ?></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -289,6 +304,25 @@ require __DIR__ . '/header.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 5px;">
|
||||
<div style="flex: 1; min-width: 140px;">
|
||||
<label class="pf-label"><?= tr('fc_carry_max') ?></label>
|
||||
<input type="number" step="0.5" id="lt-carry-max" class="pf-input" placeholder="ex: 2" value="0" required>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 140px;">
|
||||
<label class="pf-label"><?= tr('fc_carry_deadline') ?></label>
|
||||
<select id="lt-carry-deadline" class="pf-input">
|
||||
<option value="1"><?= tr('month_01') ?></option>
|
||||
<option value="2"><?= tr('month_02') ?></option>
|
||||
<option value="3"><?= tr('month_03') ?></option>
|
||||
<option value="4"><?= tr('month_04') ?></option>
|
||||
<option value="5"><?= tr('month_05') ?></option>
|
||||
<option value="6"><?= tr('month_06') ?></option>
|
||||
<option value="12"><?= tr('fc_deadline_none') ?></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 5px;">
|
||||
<button type="button" class="pf-btn btn-secondary" onclick="resetLeaveTypeForm()"><?= tr('btn_cancel') ?></button>
|
||||
<button type="button" class="pf-btn pf-btn-primary" onclick="saveLeaveType()"><?= tr('btn_save') ?></button>
|
||||
@@ -359,24 +393,36 @@ require __DIR__ . '/header.php';
|
||||
<table id="planningTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_month') ?></th>
|
||||
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_week_short') ?></th>
|
||||
<th rowspan="3" class="col-month col-sticky-mois rotated-text"><span><?= tr('fc_col_month') ?></span></th>
|
||||
<th rowspan="3" class="col-month col-sticky-sem rotated-text"><span><?= tr('fc_col_week') ?></span></th>
|
||||
<th rowspan="3" class="col-day"><?= tr('day_mon') ?></th>
|
||||
<th rowspan="3" class="col-day"><?= tr('day_tue') ?></th>
|
||||
<th rowspan="3" class="col-day"><?= tr('day_wed') ?></th>
|
||||
<th rowspan="3" class="col-day"><?= tr('day_thu') ?></th>
|
||||
<th rowspan="3" class="col-day"><?= tr('day_fri') ?></th>
|
||||
|
||||
<?php foreach ($activeCareModes as $mode): ?>
|
||||
<!-- 1. Congés de l'intervenant -->
|
||||
<?php foreach ($helpers as $helper):
|
||||
$hLeaves = $leaveMatrix[$helper['id']] ?? [];
|
||||
foreach ($hLeaves as $type):
|
||||
?>
|
||||
<th rowspan="3" class="col-total rotated-text"><span><?= htmlspecialchars($type) ?> <?= htmlspecialchars($helper['name']) ?></span></th>
|
||||
<?php endforeach; endforeach; ?>
|
||||
|
||||
<!-- 2. Méthodes de garde (sans Nounou si l'intervenant est configuré) -->
|
||||
<?php foreach ($activeCareModes as $mode):
|
||||
if (strtolower($mode) === 'nounou' && !empty($helpers)) continue;
|
||||
?>
|
||||
<th rowspan="3" class="col-total rotated-text"><span><?= htmlspecialchars($mode) ?></span></th>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- 3. Maladie par enfant -->
|
||||
<?php foreach ($kids as $kid): ?>
|
||||
<th rowspan="3" class="col-total rotated-text"><span>Maladie <?= htmlspecialchars($kid['name']) ?></span></th>
|
||||
<th rowspan="3" class="col-total rotated-text"><span><?= tr('fc_menu_sick') ?> <?= htmlspecialchars($kid['name']) ?></span></th>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php foreach ($parents as $index => $parent):
|
||||
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia'; // Couleurs alternées
|
||||
$parentClass = ($index % 2 === 0) ? 'col-p1' : 'col-p2';
|
||||
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
|
||||
$colspan = count($pLeaves) * 2;
|
||||
?>
|
||||
@@ -387,7 +433,7 @@ require __DIR__ . '/header.php';
|
||||
</tr>
|
||||
<tr>
|
||||
<?php foreach ($parents as $index => $parent):
|
||||
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
|
||||
$parentClass = ($index % 2 === 0) ? 'col-p1' : 'col-p2';
|
||||
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
|
||||
?>
|
||||
<?php foreach ($pLeaves as $type): ?>
|
||||
@@ -397,7 +443,7 @@ require __DIR__ . '/header.php';
|
||||
</tr>
|
||||
<tr>
|
||||
<?php foreach ($parents as $index => $parent):
|
||||
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
|
||||
$parentClass = ($index % 2 === 0) ? 'col-p1' : 'col-p2';
|
||||
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
|
||||
?>
|
||||
<?php foreach ($pLeaves as $type): ?>
|
||||
@@ -413,6 +459,19 @@ require __DIR__ . '/header.php';
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if (!empty($helpers)): ?>
|
||||
<section class="pf-section pf-mt-15">
|
||||
<div id="recapHelperContent"></div>
|
||||
<div style="display: none;">
|
||||
<!-- Champs masqués pour maintenir la compatibilité avec la logique JS actuelle -->
|
||||
<select id="recapHelperMonth" class="pf-input"></select>
|
||||
<select id="recapHelperYear" class="pf-input"></select>
|
||||
<input type="date" id="recapHelperStart" class="pf-input">
|
||||
<input type="date" id="recapHelperEnd" class="pf-input">
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="pf-section pf-section--bottom-panels" style="margin-top: 1.5rem;">
|
||||
<div class="pf-card">
|
||||
<h2 class="pf-card-title"><?= tr('fc_legend_title') ?></h2>
|
||||
@@ -422,15 +481,23 @@ require __DIR__ . '/header.php';
|
||||
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-school-holiday"></div><span><?= tr('leg_school_holidays') ?></span></div>
|
||||
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-public-holiday"></div><span><?= tr('leg_public_holiday') ?></span></div>
|
||||
|
||||
<!-- Intervenants (Dynamique) -->
|
||||
<?php foreach ($helpers as $helper): ?>
|
||||
<div class="pf-legend-item"><div class="pf-legend-color" style="background: var(--warning);"></div><span>Off <?= htmlspecialchars($helper['name']) ?></span></div>
|
||||
<div class="pf-legend-item"><div class="pf-legend-color" style="background: var(--danger);"></div><span>Extra <?= htmlspecialchars($helper['name']) ?></span></div>
|
||||
<?php endforeach; ?>
|
||||
<!-- Intervenants (Dynamique selon les congés paramétrés) -->
|
||||
<?php
|
||||
$helperColorIndex = 0;
|
||||
foreach ($helpers as $helper):
|
||||
$hLeaves = $leaveMatrix[$helper['id']] ?? [];
|
||||
foreach ($hLeaves as $type):
|
||||
?>
|
||||
<div class="pf-legend-item">
|
||||
<div class="pf-legend-color fc-bg-palette-<?= $helperColorIndex % 8 ?>"></div>
|
||||
<span><?= htmlspecialchars($type) ?> <?= htmlspecialchars($helper['name']) ?></span>
|
||||
</div>
|
||||
<?php $helperColorIndex++; endforeach; endforeach; ?>
|
||||
|
||||
<!-- Modes de garde (Dynamique avec icônes) -->
|
||||
<!-- Modes de garde (On masque "Nounou" si l'intervenant gère lui-même ses présences) -->
|
||||
<?php foreach ($activeCareModes as $index => $mode):
|
||||
$modeLower = strtolower($mode);
|
||||
if ($modeLower === 'nounou' && !empty($helpers)) continue;
|
||||
if ($modeLower === 'avis'): ?>
|
||||
<div class="pf-legend-item">
|
||||
<img src="/modules/family-calendar/assets/img/avis.svg" style="width:16px;height:16px;object-fit:contain;margin-right:6px;" alt="Avis">
|
||||
@@ -441,18 +508,17 @@ require __DIR__ . '/header.php';
|
||||
<span style="font-size:1.1rem; line-height:1; margin-right:6px;">🏫</span>
|
||||
<span><?= htmlspecialchars($mode) ?></span>
|
||||
</div>
|
||||
<?php else:
|
||||
$hue = ($index * 137) % 360; ?>
|
||||
<?php else: ?>
|
||||
<div class="pf-legend-item">
|
||||
<div class="pf-legend-color" style="background: hsl(<?= $hue ?>, 70%, 50%);"></div>
|
||||
<div class="pf-legend-color" style="background: var(--primary);"></div>
|
||||
<span><?= htmlspecialchars($mode) ?></span>
|
||||
</div>
|
||||
<?php endif;
|
||||
<?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'; ?>
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
// food.php
|
||||
require __DIR__ . '/includes/auth.php';
|
||||
require __DIR__ . '/includes/db.php';
|
||||
require_once __DIR__ . '/includes/i18n.php';
|
||||
require_login('/login.php');
|
||||
|
||||
// 1. Détection des vues actives
|
||||
$enabledViews = $_SESSION['enabled_views'] ?? ['food_meals', 'food_liste'];
|
||||
$allowedTabs = [];
|
||||
|
||||
if (in_array('food_meals', $enabledViews)) $allowedTabs[] = 'meals';
|
||||
if (in_array('food_liste', $enabledViews)) $allowedTabs[] = 'liste';
|
||||
|
||||
// 2. Fallback intelligent de l'onglet actif
|
||||
if (empty($allowedTabs)) {
|
||||
$tab = 'empty'; // L'utilisateur a désactivé les deux vues
|
||||
} else {
|
||||
// Par défaut, on prend le premier onglet disponible (Meals, ou Liste si Meals est désactivé)
|
||||
$defaultTab = $allowedTabs[0];
|
||||
$tab = $_GET['tab'] ?? $defaultTab;
|
||||
|
||||
// Sécurité URL : Si on force un onglet non autorisé
|
||||
if (!in_array($tab, $allowedTabs)) {
|
||||
$tab = $defaultTab;
|
||||
}
|
||||
}
|
||||
|
||||
$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>
|
||||
|
||||
<?php if ($tab !== 'empty'): ?>
|
||||
<nav class="budget-tabs-container">
|
||||
<?php if (in_array('meals', $allowedTabs)): ?>
|
||||
<a href="?tab=meals" class="tab-item <?= $tab == 'meals' ? 'active' : '' ?>">
|
||||
<span class="tab-icon">🍽️</span>
|
||||
<span><?= tr('food_tab_meals') ?></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (in_array('liste', $allowedTabs)): ?>
|
||||
<a href="?tab=liste" class="tab-item <?= $tab == 'liste' ? 'active' : '' ?>">
|
||||
<span class="tab-icon">🛒</span>
|
||||
<span><?= tr('food_tab_list') ?></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<section class="pf-section">
|
||||
<?php
|
||||
if ($tab === 'empty') {
|
||||
// État Vide Intégré (Zéro vue activée)
|
||||
echo "
|
||||
<div class='pf-empty-state' style='text-align:center; padding: 60px 20px; max-width: 600px; margin: 0 auto; background: var(--bg-panel); border-radius: 16px; border: 1px dashed var(--border-strong); box-shadow: var(--shadow-sm);'>
|
||||
<div style='font-size: 4rem; margin-bottom: 20px; opacity: 0.5;'>👀</div>
|
||||
<h3 style='color: var(--text-main); margin-bottom: 10px; font-size: 1.3rem;'>" . tr('food_err_no_views') . "</h3>
|
||||
<p style='color: var(--text-muted); margin-bottom: 30px; font-size: 0.95rem; line-height: 1.5;'>" . tr('food_err_no_views_desc') . "</p>
|
||||
<a href='/settings.php' class='pf-btn'>⚙️ " . tr('settings') . "</a>
|
||||
</div>";
|
||||
} else {
|
||||
// Routage classique
|
||||
$viewPath = __DIR__ . "/modules/food/views/{$tab}.php";
|
||||
if (file_exists($viewPath)) {
|
||||
require $viewPath;
|
||||
} else {
|
||||
echo "<p>Vue introuvable : " . htmlspecialchars($tab) . "</p>";
|
||||
}
|
||||
}
|
||||
?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/footer.php'; ?>
|
||||
+671
-13
@@ -410,25 +410,124 @@ body.pf-home .pf-mobile-close-btn {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Tuile module (checkbox) — état coché via :has(), compatible thème */
|
||||
.pf-module-tile {
|
||||
/* --- MODULES & SOUS-VUES UNIFIÉS (Settings) --- */
|
||||
.pf-module-card-wrapper {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
padding: 12px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-page);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.pf-module-tile:has(input:checked) {
|
||||
|
||||
/* Le fond bleu léger s'applique à TOUTE la carte si le module est actif */
|
||||
.pf-module-card-wrapper:has(input.js-mod-checkbox:checked) {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
|
||||
/* Ligne supérieure : Titre + Pills (Alignement horizontal + Wrap auto) */
|
||||
.pf-module-top-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pf-module-tile-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pf-tile-icon {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pf-tile-label {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* --- Sous-Vues (Pills) --- */
|
||||
.pf-module-inline-pills {
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pf-module-card-wrapper:has(input.js-mod-checkbox:checked)
|
||||
.pf-module-inline-pills {
|
||||
display: flex;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.pf-sub-module-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
border-radius: 50px;
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-strong);
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pf-sub-module-pill input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pf-sub-module-pill .pf-pill-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.pf-sub-module-pill:hover {
|
||||
border-color: var(--text-main);
|
||||
}
|
||||
|
||||
/* État actif de la Pill */
|
||||
.pf-sub-module-pill:has(input:checked) {
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 1px 2px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.pf-sub-module-pill:has(input:checked) .pf-pill-text {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Note informative en bas de carte */
|
||||
.pf-module-info-footer {
|
||||
display: none;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
}
|
||||
|
||||
.pf-module-card-wrapper:has(input.js-mod-checkbox:checked)
|
||||
.pf-module-info-footer {
|
||||
display: block;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.pf-checkbox-lg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -445,6 +544,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 +1500,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;
|
||||
}
|
||||
+132
-52
@@ -23,6 +23,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/favicon.png">
|
||||
<link rel="stylesheet" href="/global.css">
|
||||
@@ -55,7 +56,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 +107,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; ?>
|
||||
@@ -122,19 +123,13 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
||||
<main class="pf-main<?= ($mainClass ?? '') ? ' '.htmlspecialchars($mainClass) : '' ?>">
|
||||
|
||||
<script>
|
||||
// CSRF avant I18N : le parseur HTML coupe le script dès la séquence de fin de balise « script » (même dans un commentaire JS).
|
||||
// CSRF avant I18N
|
||||
window.CSRF_TOKEN = "<?= htmlspecialchars(function_exists('csrf_token') ? csrf_token() : '', ENT_QUOTES, 'UTF-8') ?>";
|
||||
|
||||
/**
|
||||
* Pont d'Internationalisation et Configuration
|
||||
*/
|
||||
window.I18N = <?php echo json_encode($current_translations_array ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE); ?>;
|
||||
|
||||
// ✅ Injection sécurisée
|
||||
window.CONFIG = {
|
||||
// Liste complète des profils de la famille (Membres, Nounou, Enfants)
|
||||
PEOPLE: <?= isset($familyPeople) ? json_encode($familyPeople) : '[]' ?>,
|
||||
// ID du user global connecté (pour savoir "qui" clique)
|
||||
CURRENT_USER_ID: <?= isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : 'null' ?>,
|
||||
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>',
|
||||
ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>'
|
||||
@@ -144,14 +139,20 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
||||
return window.I18N[key] || key;
|
||||
}
|
||||
|
||||
// Thème clair/sombre — état visuel géré entièrement par CSS via [data-theme="dark"]
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(err => {
|
||||
console.error('Service Worker registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTheme(){
|
||||
const dark=document.documentElement.getAttribute('data-theme')==='dark';
|
||||
document.documentElement.setAttribute('data-theme',dark?'light':'dark');
|
||||
localStorage.setItem('hh-theme',dark?'light':'dark');
|
||||
}
|
||||
|
||||
// Gestion du menu mobile (Off-Canvas Sidebar)
|
||||
<?php if (isset($_SESSION['user'])): ?>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const burgerBtn = document.querySelector('.pf-burger-btn');
|
||||
@@ -174,14 +175,13 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
||||
if(burgerBtn && mobileMenu && overlay && closeBtn) {
|
||||
burgerBtn.addEventListener('click', openMenu);
|
||||
closeBtn.addEventListener('click', closeMenu);
|
||||
// Fermer le menu si on clique en dehors (sur l'overlay sombre)
|
||||
overlay.addEventListener('click', closeMenu);
|
||||
}
|
||||
});
|
||||
<?php endif; ?>
|
||||
|
||||
/**
|
||||
* pachaFetch : Utilitaire de requête robuste
|
||||
* pachaFetch : Utilitaire de requête robuste avec gestion Offline Cross-Browser
|
||||
*/
|
||||
async function pachaFetch(url, options = {}) {
|
||||
const finalUrl = url.startsWith('/') ? url.substring(1) : url;
|
||||
@@ -200,53 +200,133 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
||||
try {
|
||||
return JSON.parse(rawText);
|
||||
} catch (jsonErr) {
|
||||
console.error("Réponse corrompue (HTML reçu au lieu de JSON) :", rawText);
|
||||
console.error("Réponse corrompue (HTML) :", rawText);
|
||||
throw new Error("Erreur serveur : format JSON invalide.");
|
||||
}
|
||||
} catch (err) {
|
||||
// Si hors ligne ET modification (POST/PUT/DELETE)
|
||||
if (!navigator.onLine && options.method && options.method.toUpperCase() !== 'GET') {
|
||||
await enqueueOfflineAction(finalUrl, options);
|
||||
|
||||
// Chrome/Android : Déclenche le Background Sync
|
||||
if ('serviceWorker' in navigator && 'SyncManager' in window) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.sync.register('househub-sync').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
showToast("Mémorisé hors ligne 📡", "success");
|
||||
return { success: true, offline: true };
|
||||
}
|
||||
|
||||
console.error(`Erreur pachaFetch [${finalUrl}] :`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Utility : Toasts (Notifications)
|
||||
*/
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `pf-toast pf-toast--${type}`;
|
||||
toast.innerHTML = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Animation et suppression
|
||||
setTimeout(() => toast.classList.add('is-visible'), 100);
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('is-visible');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
* Utilitaire PWA : Ajout à la file d'attente (IndexedDB)
|
||||
*/
|
||||
function enqueueOfflineAction(url, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('househub-offline-sync', 1);
|
||||
request.onupgradeneeded = event => {
|
||||
event.target.result.createObjectStore('sync-queue', { autoIncrement: true });
|
||||
};
|
||||
request.onsuccess = event => {
|
||||
const db = event.target.result;
|
||||
const tx = db.transaction('sync-queue', 'readwrite');
|
||||
const store = tx.objectStore('sync-queue');
|
||||
|
||||
let bodyData = options.body;
|
||||
if (bodyData instanceof FormData) {
|
||||
bodyData = new URLSearchParams(bodyData).toString();
|
||||
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Utility : Confirmation stylisée (Remplace confirm())
|
||||
*/
|
||||
async function pachaConfirm(title, message) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pf-modal open';
|
||||
modal.innerHTML = `
|
||||
<div class="pf-modal-content" style="max-width: 400px; align-self: center;">
|
||||
<h3 style="margin-top:0;">${title}</h3>
|
||||
<p style="color:var(--text-muted);">${message}</p>
|
||||
<div class="modal-footer">
|
||||
<button class="pf-btn btn-secondary" id="confirm-cancel">${tr('btn_cancel')}</button>
|
||||
<button class="pf-btn" id="confirm-ok" style="background:var(--danger);">${tr('btn_delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById('confirm-cancel').onclick = () => { modal.remove(); resolve(false); };
|
||||
document.getElementById('confirm-ok').onclick = () => { modal.remove(); resolve(true); };
|
||||
store.add({
|
||||
url: url,
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
body: bodyData,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
request.onerror = event => reject(event.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback pour Firefox et iOS Safari (Dépilement manuel au retour réseau)
|
||||
*/
|
||||
window.addEventListener('online', () => {
|
||||
const request = indexedDB.open('househub-offline-sync', 1);
|
||||
request.onsuccess = event => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains('sync-queue')) return;
|
||||
|
||||
const tx = db.transaction('sync-queue', 'readonly');
|
||||
const store = tx.objectStore('sync-queue');
|
||||
const getAll = store.getAll();
|
||||
|
||||
getAll.onsuccess = async () => {
|
||||
const actions = getAll.result;
|
||||
if (actions && actions.length > 0) {
|
||||
showToast("Synchronisation des données... 🔄", "success");
|
||||
for (const action of actions) {
|
||||
try {
|
||||
await fetch(action.url, {
|
||||
method: action.method,
|
||||
headers: action.headers,
|
||||
body: action.body
|
||||
});
|
||||
const delTx = db.transaction('sync-queue', 'readwrite');
|
||||
delTx.objectStore('sync-queue').delete(action.id);
|
||||
} catch (e) {
|
||||
console.error("Échec de la synchro", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
showToast("Synchronisation terminée ! ✅", "success");
|
||||
// Rafraîchissement léger recommandé après synchro de masse
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
}
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `pf-toast pf-toast--${type}`;
|
||||
toast.innerHTML = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.classList.add('is-visible'), 100);
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('is-visible');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function pachaConfirm(title, message) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pf-modal open';
|
||||
modal.innerHTML = `
|
||||
<div class="pf-modal-content" style="max-width: 400px; align-self: center;">
|
||||
<h3 style="margin-top:0;">${title}</h3>
|
||||
<p style="color:var(--text-muted);">${message}</p>
|
||||
<div class="modal-footer">
|
||||
<button class="pf-btn btn-secondary" id="confirm-cancel">${tr('btn_cancel')}</button>
|
||||
<button class="pf-btn" id="confirm-ok" style="background:var(--danger);">${tr('btn_delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById('confirm-cancel').onclick = () => { modal.remove(); resolve(false); };
|
||||
document.getElementById('confirm-ok').onclick = () => { modal.remove(); resolve(true); };
|
||||
});
|
||||
}
|
||||
</script>
|
||||
+13
-2
@@ -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';
|
||||
@@ -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,48 @@ 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, f.enabled_views
|
||||
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);
|
||||
$_SESSION['enabled_views'] = json_decode($user['enabled_views'] ?? '["food_meals","food_liste"]', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ---------------------------------------
|
||||
|
||||
if (empty($_SESSION['user'])) {
|
||||
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false)) {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
+45
-38
@@ -1,52 +1,59 @@
|
||||
<?php
|
||||
// includes/db.php
|
||||
|
||||
if (file_exists(__DIR__ . '/config.php')) {
|
||||
require_once __DIR__ . '/config.php';
|
||||
}
|
||||
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$host = getenv('DB_HOST') ?: 'househub-db';
|
||||
$user = getenv('DB_USER') ?: 'househub';
|
||||
$pass = getenv('DB_PASS') ?: 'changeme';
|
||||
$db = $_SESSION['family_db'] ?? null;
|
||||
$envHost = getenv('DB_HOST');
|
||||
|
||||
if (!$db) {
|
||||
$current = basename($_SERVER['PHP_SELF'] ?? '');
|
||||
if (!in_array($current, ['login.php', 'register.php'])) {
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
return;
|
||||
// 1. Détection stricte de l'environnement
|
||||
if ($envHost === 'househub-db') {
|
||||
// Docker
|
||||
$host = 'househub-db';
|
||||
$user = getenv('DB_USER') ?: 'househub';
|
||||
$pass = getenv('DB_PASS') ?: 'changeme';
|
||||
} else {
|
||||
// XAMPP
|
||||
$host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
|
||||
$user = 'root';
|
||||
$pass = '';
|
||||
}
|
||||
|
||||
// 2. Multi-tenant strict : cibler la DB de la famille courante
|
||||
$db = 'househub_meta'; // Fallback par défaut
|
||||
if (!empty($_SESSION['user']['family_id'])) {
|
||||
$db = 'househub_f' . (int)$_SESSION['user']['family_id'];
|
||||
}
|
||||
|
||||
$charset = 'utf8mb4';
|
||||
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
|
||||
PDO::ATTR_TIMEOUT => 30,
|
||||
];
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"mysql:host=$host;dbname=$db;charset=utf8mb4",
|
||||
$user, $pass,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
|
||||
PDO::ATTR_TIMEOUT => 30,
|
||||
]
|
||||
);
|
||||
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
|
||||
$pdo = new PDO($dsn, $user, $pass, $options);
|
||||
|
||||
try {
|
||||
$foyer = $pdo->query("SELECT currency, zone_scolaire FROM pf_foyer_settings WHERE id = 1")->fetch();
|
||||
if (!defined('CURRENCY')) {
|
||||
define('CURRENCY', $foyer['currency'] ?? '€');
|
||||
}
|
||||
if (!defined('ZONE_SCOLAIRE')) {
|
||||
define('ZONE_SCOLAIRE', $foyer['zone_scolaire'] ?? 'C');
|
||||
}
|
||||
} catch (\PDOException $e) {
|
||||
if (!defined('CURRENCY')) define('CURRENCY', '€');
|
||||
if (!defined('ZONE_SCOLAIRE')) define('ZONE_SCOLAIRE', 'C');
|
||||
}
|
||||
// ------------------------------------------------
|
||||
// Forcer la collation
|
||||
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
|
||||
$pdo->exec("SET collation_database = utf8mb4_general_ci");
|
||||
$pdo->exec("SET collation_server = utf8mb4_general_ci");
|
||||
|
||||
} catch (\PDOException $e) {
|
||||
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||
die(json_encode(['ok' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]));
|
||||
die("Erreur de connexion BDD (Host: $host, DB: $db) : " . $e->getMessage());
|
||||
}
|
||||
?>
|
||||
+446
-364
File diff suppressed because it is too large.
Load diff
+485
-384
File diff suppressed because it is too large.
Load diff
+520
-437
File diff suppressed because it is too large.
Load diff
+20
-4
@@ -1,9 +1,23 @@
|
||||
<?php
|
||||
// includes/meta_db.php
|
||||
// Connexion à la base meta (gestion des familles et utilisateurs)
|
||||
$meta_host = getenv('DB_HOST') ?: 'househub-db';
|
||||
$meta_db = 'househub_meta';
|
||||
$meta_user = getenv('DB_USER') ?: 'househub';
|
||||
$meta_pass = getenv('DB_PASS') ?: 'changeme';
|
||||
|
||||
$envHost = getenv('DB_HOST');
|
||||
|
||||
// Détection stricte : Docker injecte toujours 'househub-db' via le docker-compose
|
||||
if ($envHost === 'househub-db') {
|
||||
// Environnement Docker (Ton collègue ou la Prod)
|
||||
$meta_host = 'househub-db';
|
||||
$meta_user = getenv('DB_USER') ?: 'househub';
|
||||
$meta_pass = getenv('DB_PASS') ?: 'changeme';
|
||||
} else {
|
||||
// Environnement Local XAMPP (Toi)
|
||||
$meta_host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
|
||||
$meta_user = 'root';
|
||||
$meta_pass = '';
|
||||
}
|
||||
|
||||
$meta_db = 'househub_meta';
|
||||
|
||||
try {
|
||||
$meta_pdo = new PDO(
|
||||
@@ -14,8 +28,10 @@ try {
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
|
||||
]
|
||||
);
|
||||
} catch (\PDOException $e) {
|
||||
die(json_encode(['error' => 'Meta DB unavailable: ' . $e->getMessage()]));
|
||||
}
|
||||
?>
|
||||
@@ -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">
|
||||
|
||||
@@ -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'; ?>
|
||||
@@ -25,8 +25,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$error = tr('error_missing_fields');
|
||||
} else {
|
||||
$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,
|
||||
f.db_name, f.is_active as family_active, f.enabled_modules
|
||||
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, f.enabled_views
|
||||
FROM users u
|
||||
LEFT JOIN families f ON f.id = u.family_id
|
||||
WHERE u.username = ?
|
||||
@@ -51,6 +51,21 @@ 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);
|
||||
$_SESSION['enabled_views'] = json_decode($user['enabled_views'] ?? '["food_meals","food_liste"]', 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 +110,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>
|
||||
|
||||
+16
-2
@@ -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;
|
||||
exit;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "HouseHub OS",
|
||||
"short_name": "HouseHub",
|
||||
"description": "Système de gestion familiale",
|
||||
"start_url": "/index.php",
|
||||
"display": "standalone",
|
||||
"background_color": "#f8fafc",
|
||||
"theme_color": "#3b82f6",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
+21
-42
@@ -1,49 +1,28 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/meta_db.php';
|
||||
// migrate.php
|
||||
|
||||
$db_host = getenv('DB_HOST') ?: '127.0.0.1';
|
||||
$db_user = getenv('DB_USER') ?: 'househub';
|
||||
$db_pass = getenv('DB_PASS') ?: 'househub_dev';
|
||||
|
||||
echo "<h1>🗺️ Migration : Refonte Modèle Voyages</h1><ul>";
|
||||
// 1. Initialisation autonome de la connexion Meta
|
||||
require __DIR__ . '/includes/meta_db.php';
|
||||
|
||||
// 2. FIX CHICKEN & EGG 🛑 : Création de la colonne AVANT de charger l'authentification
|
||||
try {
|
||||
$stmt = $meta_pdo->query("SELECT db_name, name FROM families WHERE is_active = 1");
|
||||
$families = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($families as $family) {
|
||||
$dbName = $family['db_name'];
|
||||
try {
|
||||
$pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
|
||||
// 1. Mise à jour pf_holidays
|
||||
$pdo->exec("ALTER TABLE pf_holidays ADD COLUMN return_step_id INT DEFAULT NULL");
|
||||
|
||||
// 2. Mise à jour pf_holidays_items
|
||||
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN step_type VARCHAR(20) DEFAULT 'stop'");
|
||||
$pdo->exec("ALTER TABLE pf_holidays_items ADD COLUMN expense_context VARCHAR(20) DEFAULT NULL");
|
||||
|
||||
// 3. Migration des données (is_return -> return_step_id)
|
||||
// On cherche la première étape cochée "is_return" et on l'assigne au voyage
|
||||
$pdo->exec("
|
||||
UPDATE pf_holidays h
|
||||
SET return_step_id = (
|
||||
SELECT id FROM pf_holidays_items i
|
||||
WHERE i.holiday_id = h.id AND i.is_return = 1 AND i.location_name IS NOT NULL
|
||||
LIMIT 1
|
||||
)
|
||||
");
|
||||
|
||||
// 4. Nettoyage de l'ancienne colonne
|
||||
$pdo->exec("ALTER TABLE pf_holidays_items DROP COLUMN is_return");
|
||||
|
||||
echo "<li>✅ {$family['name']} : Schéma Voyages mis à jour !</li>";
|
||||
} catch (\PDOException $e) {
|
||||
echo "<li>⚠️ {$family['name']} : " . $e->getMessage() . "</li>";
|
||||
}
|
||||
$checkMetaCol = $meta_pdo->query("SHOW COLUMNS FROM families LIKE 'enabled_views'");
|
||||
if ($checkMetaCol->rowCount() == 0) {
|
||||
$meta_pdo->exec("ALTER TABLE families ADD COLUMN enabled_views JSON DEFAULT NULL AFTER enabled_modules");
|
||||
echo "✅ Colonne 'enabled_views' ajoutée à la table families (Bypass Auth).<br><br>";
|
||||
}
|
||||
echo "</ul><h2>🎉 Migration terminée !</h2>";
|
||||
} catch (Exception $e) {
|
||||
die("Erreur fatale : " . $e->getMessage());
|
||||
die("❌ Erreur critique lors de la mise à jour structurelle : " . $e->getMessage());
|
||||
}
|
||||
?>
|
||||
|
||||
// 3. Chargement sécurisé de l'authentification (qui fonctionne maintenant)
|
||||
require __DIR__ . '/includes/auth.php';
|
||||
require_login();
|
||||
|
||||
// 4. Sécurisation de la page
|
||||
if (empty($_SESSION['user']['is_admin'])) {
|
||||
die("Accès refusé. Droits administrateur requis.");
|
||||
}
|
||||
|
||||
echo "<h1>Migration Meta</h1>";
|
||||
echo "🎉 L'infrastructure est à jour et la session a pu démarrer correctement.";
|
||||
@@ -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>");
|
||||
}
|
||||
@@ -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>";
|
||||
}
|
||||
?>
|
||||
+1073
-137
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,285 @@
|
||||
<?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 getSettingString(string $key, string $default = ''): string {
|
||||
return isset($this->settings[$key]) ? (string)$this->settings[$key] : $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 {
|
||||
// 🔥 FIX STATE OF THE ART : On cible uniquement la population "Élèves"
|
||||
$prevYear = $this->year - 1;
|
||||
$nextYear = $this->year + 1;
|
||||
$where = "(annee_scolaire='{$prevYear}-{$this->year}' OR annee_scolaire='{$this->year}-{$nextYear}') AND zones LIKE '%Zone {$zone}%' AND population = 'Élèves'";
|
||||
|
||||
$url = "https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=" . urlencode($where) . "&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));
|
||||
// Sécurité : si la vacance commence le vendredi soir, on la compte à partir du samedi
|
||||
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);
|
||||
$overridesJson = $this->getSettingString('budget_cesu_overrides', '{}');
|
||||
$cesuOverrides = json_decode($overridesJson, true) ?: [];
|
||||
|
||||
$monthKey = sprintf("%04d-%02d", $this->year, $this->month);
|
||||
$isCesuOverride = isset($cesuOverrides[$monthKey]);
|
||||
$cesuApplied = $isCesuOverride ? (float)$cesuOverrides[$monthKey] : $cesuAvg;
|
||||
$cesuLabelText = $isCesuOverride ? tr('bud_audit_cesu_adj') : tr('bud_audit_cesu_avg');
|
||||
// ------------------------------------
|
||||
|
||||
$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 = (int)date('t', strtotime(sprintf("%04d-%02d-01", $this->year, $this->month)));
|
||||
|
||||
// 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;
|
||||
// On soustrait $cesuApplied au lieu de $cesuAvg
|
||||
$nannyGlobalCost = $nannyFixed + $totalNannyFees - $nannyAid - $cesuApplied;
|
||||
|
||||
// 3. Rédaction du Bilan
|
||||
if ($hasNanny) {
|
||||
$totalCost += max(0, $nannyGlobalCost);
|
||||
// On passe $cesuLabelText au sprintf
|
||||
$details .= sprintf(tr('bud_audit_nanny_full') . "\n",
|
||||
$this->helperName, $nannyFixed, $totalNannyDays, $nannyDaily, $totalNannyFees, $nannyAid, $cesuLabelText, $cesuApplied, 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()]);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../../../includes/auth.php';
|
||||
require __DIR__ . '/../../../../includes/db.php';
|
||||
require_once __DIR__ . '/../../../../includes/i18n.php';
|
||||
require_login();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
@@ -45,16 +46,25 @@ try {
|
||||
$currencySetting = $foyerData['currency'] ?? '€';
|
||||
$csvMapping = !empty($foyerData['csv_mapping']) ? json_decode($foyerData['csv_mapping'], true) : null;
|
||||
|
||||
// --- NOUVEAU : Récupération des paramètres dynamiques ---
|
||||
$stmtSettings = $pdo->query("SELECT setting_key, setting_value FROM pf_settings WHERE module = 'budget'");
|
||||
$budgetSettings = $stmtSettings->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// 6. Liste des membres (pour assigner un compte bancaire)
|
||||
$people = $pdo->query("SELECT id, name FROM pf_people WHERE is_active = 1 ORDER BY name ASC")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'accounts' => $accounts,
|
||||
'categories' => $categories,
|
||||
'rules' => $rules,
|
||||
'salaries' => $salaries,
|
||||
'year' => $currentYear,
|
||||
'currency' => $currencySetting,
|
||||
'csv_mapping' => $csvMapping
|
||||
'accounts' => $accounts,
|
||||
'categories' => $categories,
|
||||
'rules' => $rules,
|
||||
'salaries' => $salaries,
|
||||
'people' => $people,
|
||||
'year' => $currentYear,
|
||||
'currency' => $currencySetting,
|
||||
'csv_mapping' => $csvMapping,
|
||||
'budget_settings' => $budgetSettings
|
||||
]
|
||||
]);
|
||||
exit;
|
||||
@@ -64,9 +74,10 @@ try {
|
||||
if ($action === 'add_account') {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$type = $_POST['type'] ?? 'checking';
|
||||
$owner_id = !empty($_POST['owner_person_id']) ? (int)$_POST['owner_person_id'] : null;
|
||||
if (empty($name)) throw new Exception("Le nom du compte est obligatoire.");
|
||||
$stmt = $pdo->prepare("INSERT INTO pf_bank_accounts (name, account_type, is_default) VALUES (?, ?, 0)");
|
||||
$stmt->execute([$name, $type]);
|
||||
$stmt = $pdo->prepare("INSERT INTO pf_bank_accounts (name, account_type, is_default, owner_person_id) VALUES (?, ?, 0, ?)");
|
||||
$stmt->execute([$name, $type, $owner_id]);
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
}
|
||||
@@ -100,6 +111,15 @@ try {
|
||||
|
||||
if ($action === 'delete_category') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
$stmtCheck = $pdo->prepare("SELECT code FROM pf_budget_categories WHERE id = ?");
|
||||
$stmtCheck->execute([$id]);
|
||||
$catCode = $stmtCheck->fetchColumn();
|
||||
|
||||
if ($catCode && in_array(strtoupper($catCode), ['FIXED', 'INCOME'])) {
|
||||
throw new Exception(tr('bud_err_delete_system_cat'));
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM pf_budget_categories WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['success' => true]);
|
||||
@@ -181,9 +201,31 @@ 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',
|
||||
'budget_cesu_overrides'
|
||||
];
|
||||
|
||||
$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()]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
// modules/budget/views/budget_prev.php
|
||||
|
||||
// 1. Chargement dynamique des ADULTES de la famille
|
||||
$stmtPeople = $pdo->query("SELECT id, name, user_id, role, color FROM pf_people WHERE role NOT IN ('enfant', 'nounou') AND is_active = 1 ORDER BY id ASC");
|
||||
// 1. Chargement dynamique des ADULTES de la famille (Exclusion stricte des rôles secondaires)
|
||||
$stmtPeople = $pdo->query("SELECT id, name, user_id, role, color FROM pf_people WHERE role IN ('parent') AND is_active = 1 ORDER BY id ASC");
|
||||
$budgetParents = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Sécurité : au cas où aucun adulte n'est trouvé, on évite un crash
|
||||
@@ -277,7 +277,7 @@ if (count($parentMapping) >= 2) {
|
||||
</tr>
|
||||
|
||||
<?php foreach ($cats as $cat):
|
||||
$isIndicative = (strpos($cat['name'], 'Eco P') === 0);
|
||||
$isIndicative = (preg_match('/^(Eco\s|Eco P\d|Livret)/i', $cat['name']) === 1);
|
||||
$rowClass = $isIndicative ? 'row-indicative' : '';
|
||||
$inputClass = $isIndicative ? 'ignore-calc' : '';
|
||||
|
||||
@@ -306,7 +306,7 @@ if (count($parentMapping) >= 2) {
|
||||
data-transfer-dest="<?= htmlspecialchars($cat['transfer_dest'] ?? '') ?>"
|
||||
data-holiday="<?= $cat['holiday_id'] ?? '' ?>"
|
||||
onclick="openEditModal(this)">✎</button>
|
||||
<button type="button" onclick="deleteCategory(<?= $cat['id'] ?>)" class="btn-icon-action delete" title="<?= tr('delete') ?>">🗑️</button>
|
||||
<button type="button" onclick="deleteAllocCategory(<?= $cat['id'] ?>)" class="btn-icon-action delete" title="<?= tr('delete') ?>">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
<?php foreach ($months as $m): ?>
|
||||
@@ -794,7 +794,7 @@ function duplicateMonth() {
|
||||
const parentMap = window.CONFIG.parentMapping;
|
||||
|
||||
if (!sourceDateStr) {
|
||||
alert(tr('bud_prev_err_no_history'));
|
||||
showToast(tr('bud_prev_err_no_history'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -955,10 +955,10 @@ async function validateTransfers(personCss, month) {
|
||||
if(result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(tr('bud_err_tech') + " : " + result.error);
|
||||
showToast(tr('bud_err_tech') + " : " + (result.error || "Inconnue"), 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
alert(tr('bud_err_tech'));
|
||||
showToast(tr('bud_err_tech'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,7 +986,7 @@ async function saveGenericNote(noteType, refId, content) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCategory(id) {
|
||||
async function deleteAllocCategory(id) {
|
||||
if (!confirm(tr('bud_prev_confirm_del_line'))) return;
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'delete_category');
|
||||
@@ -1035,13 +1035,13 @@ async function handleAdvanceSubmit(event, form) {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert((tr('bud_err_tech') || "Erreur") + " : " + (result.error || "Opération échouée"));
|
||||
showToast((tr('bud_err_tech') || "Erreur") + " : " + (result.error || "Opération échouée"), 'error');
|
||||
btnSubmit.disabled = false;
|
||||
btnSubmit.innerHTML = oldText;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("AJAX Error:", err);
|
||||
alert(tr('bud_err_tech') || "Une erreur technique est survenue.");
|
||||
showToast(tr('bud_err_tech') || "Une erreur technique est survenue.", 'error');
|
||||
btnSubmit.disabled = false;
|
||||
btnSubmit.innerHTML = oldText;
|
||||
}
|
||||
@@ -1155,7 +1155,7 @@ document.querySelectorAll('#addCatModal form, #editCatModal form').forEach(form
|
||||
alert((tr('bud_err_tech') || 'Erreur') + " : " + (result.error || "Inconnue"));
|
||||
}
|
||||
} catch (error) {
|
||||
alert(tr('bud_err_tech') || "Une erreur technique est survenue.");
|
||||
showToast(tr('bud_err_tech') || "Une erreur technique est survenue.", 'error');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerText = originalText;
|
||||
|
||||
+185
-183
@@ -2,24 +2,24 @@
|
||||
// modules/budget/views/epargne.php
|
||||
|
||||
// --- 🧠 CONFIGURATION AGNOSTIQUE DES MEMBRES (MULTI-TENANT) ---
|
||||
$stmtPeople = $pdo->query("SELECT name, role FROM pf_people ORDER BY id ASC");
|
||||
$stmtPeople = $pdo->query("SELECT name, role, color FROM pf_people ORDER BY id ASC");
|
||||
$familyParents = [];
|
||||
$familyKids = [];
|
||||
$personColors = [];
|
||||
|
||||
while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) {
|
||||
$role = strtolower(trim($row['role'] ?? ''));
|
||||
$personColors[$row['name']] = $row['color'] ?? '#3b82f6';
|
||||
|
||||
if ($role === 'parent') {
|
||||
$familyParents[] = $row['name'];
|
||||
} elseif ($role === 'nounou' || $role === 'helper') {
|
||||
// On ignore la nounou dans l'épargne
|
||||
continue;
|
||||
} else {
|
||||
$familyKids[] = $row['name'];
|
||||
}
|
||||
}
|
||||
|
||||
// Sécurité anti-page blanche si la BDD est mal configurée
|
||||
if (empty($familyParents)) $familyParents = ['Parent 1', 'Parent 2'];
|
||||
|
||||
$requestedOwner = $_GET['owner'] ?? (!empty($familyKids) ? 'KIDS' : ($familyParents[0] ?? 'KIDS'));
|
||||
@@ -46,10 +46,9 @@ function getMonthName($dateString) {
|
||||
<div class="budget-view">
|
||||
<div class="view-header">
|
||||
<div class="owner-tabs">
|
||||
|
||||
<?php if (!empty($familyKids)): ?>
|
||||
<a href="?tab=epargne&owner=KIDS" class="owner-tab <?= $requestedOwner === 'KIDS' ? 'active' : '' ?>">
|
||||
<?= tr('budget_tab_kids') ?? 'Enfants 👶' ?>
|
||||
<?= tr('budget_tab_kids') ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -81,181 +80,174 @@ function getMonthName($dateString) {
|
||||
$months = array_slice($months, 0, 7);
|
||||
sort($allCategories);
|
||||
|
||||
$ownerTextClass = 'txt-global';
|
||||
// 🔄 CORRECTION ICI : Création d'un nom "safe" sans espace pour les classes CSS
|
||||
$safeOwnerCls = htmlspecialchars(str_replace(' ', '_', $currentOwner), ENT_QUOTES);
|
||||
$ownerColor = $personColors[$currentOwner] ?? '#1e293b';
|
||||
?>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; margin-top: <?= ($requestedOwner === 'KIDS' && $index > 0) ? '40px' : '0' ?>;">
|
||||
<div style="flex-grow: 1;">
|
||||
<?php if ($requestedOwner === 'KIDS'):
|
||||
$themeClass = 'theme-' . strtolower($safeOwnerCls);
|
||||
?>
|
||||
<h3 class="nens-title <?= $themeClass ?>" style="margin:0; font-size:1.2rem;">
|
||||
<div class="epargne-header-bar <?= ($requestedOwner === 'KIDS' && $index > 0) ? 'pf-mt-40' : '' ?>">
|
||||
<div class="pf-flex-1">
|
||||
<?php if ($requestedOwner === 'KIDS'): ?>
|
||||
<h3 class="nens-title" style="--owner-color: <?= htmlspecialchars($ownerColor) ?>;">
|
||||
<?= htmlspecialchars($currentOwner) ?>
|
||||
</h3>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<div class="pf-flex-gap-12-center">
|
||||
<?php if (!empty($months)): ?>
|
||||
<button onclick="duplicateLastMonth('<?= $months[0] ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn btn-secondary">
|
||||
🔁 <?= tr('bud_sav_add_one_month') ?>
|
||||
</button>
|
||||
<button onclick="promptNewSavingsLine('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', '<?= $months[0] ?>')" class="pf-btn">
|
||||
+ <?= tr('bud_sav_new_line') ?>
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<button onclick="openCustomSavingsModal('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn">
|
||||
+ <?= tr('bud_sav_add_month') ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button onclick="openCustomSavingsModal('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn">
|
||||
+ <?= tr('bud_sav_add_month') ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive" style="background:white; border-radius:16px; box-shadow:var(--shadow-sm); border:1px solid #e2e8f0;">
|
||||
<div class="table-responsive pf-card-epargne">
|
||||
<?php if (empty($months)): ?>
|
||||
<div style="padding: 30px; text-align: center; color: #64748b;">
|
||||
<div class="pf-empty-dashed">
|
||||
<p><?= sprintf(tr('bud_sav_no_data'), htmlspecialchars($currentOwner)) ?></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="pf-table savings-table nens-table theme-<?= strtolower($safeOwnerCls) ?>" style="margin-top:0; box-shadow:none; border-radius:16px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sticky-col" style="background:#f8fafc;"><?= tr('bud_sav_post_month') ?></th>
|
||||
<?php foreach ($months as $month): ?>
|
||||
<th>
|
||||
<div class="month-header-container" style="display: flex; flex-direction: column; align-items: center; gap: 6px;">
|
||||
<div style="display:flex; flex-direction:column; text-align:center;">
|
||||
<span class="month-name" style="text-transform:capitalize;"><?= getMonthName($month) ?></span>
|
||||
<?php
|
||||
if (isset($cycleConfigs[$month]) && !empty($cycleConfigs[$month]['start_date'])) {
|
||||
$cStart = date('d/m', strtotime($cycleConfigs[$month]['start_date']));
|
||||
echo "<span style='font-size:0.75rem; font-weight:normal; color:#64748b;'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</span>";
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<div class="month-actions" style="justify-content: center; width: 100%;">
|
||||
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_edit_modal') ?>"
|
||||
data-json="<?= htmlspecialchars(json_encode($data[$month] ?? []), ENT_QUOTES, 'UTF-8') ?>"
|
||||
onclick='editCustomSavingsMonth("<?= $month ?>", "<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>", JSON.parse(this.getAttribute("data-json")))'>
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_delete_month') ?>"
|
||||
onclick="deleteEntireMonth('<?= $month ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')"
|
||||
style="color: #ef4444; border-color: #fca5a5; background: #fef2f2;">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
<div 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): ?>
|
||||
<div class="eg-cell">
|
||||
<div class="month-header-container">
|
||||
<div class="month-header-title">
|
||||
<span class="month-name text-inherit"><?= getMonthName($month) ?></span>
|
||||
<?php
|
||||
if (isset($cycleConfigs[$month]) && !empty($cycleConfigs[$month]['start_date'])) {
|
||||
$cStart = date('d/m', strtotime($cycleConfigs[$month]['start_date']));
|
||||
echo "<span class='month-cycle-start'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</span>";
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</th>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="row-total">
|
||||
<td class="sticky-col"><strong><?= tr('bud_sav_total_bank') ?></strong></td>
|
||||
<?php foreach ($months as $month):
|
||||
$val = $data[$month]['TOTAL_BANQUE'] ?? 0;
|
||||
?>
|
||||
<td class="text-center" style="padding:4px;">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
|
||||
<input type="number" step="0.01"
|
||||
class="prev-input total-input-<?= $safeOwnerCls ?>-<?= $month ?>"
|
||||
style="width: 70px; font-weight:bold; color:#2563eb;"
|
||||
value="<?= $val != 0 ? round($val) : '' ?>"
|
||||
placeholder="0"
|
||||
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
|
||||
<span style="color:#2563eb; font-weight:bold; font-size:0.9rem;">€</span>
|
||||
<div class="month-actions">
|
||||
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_edit_modal') ?>"
|
||||
data-json="<?= htmlspecialchars(json_encode($data[$month] ?? []), ENT_QUOTES, 'UTF-8') ?>"
|
||||
onclick='editCustomSavingsMonth("<?= $month ?>", "<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>", JSON.parse(this.getAttribute("data-json")))'>
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-icon-small btn-safe-click btn-danger-soft" title="<?= tr('bud_sav_delete_month') ?>"
|
||||
onclick="deleteEntireMonth('<?= $month ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
|
||||
<?php foreach ($allCategories as $cat): ?>
|
||||
<tr>
|
||||
<td class="sticky-col"><?= htmlspecialchars($cat) ?></td>
|
||||
<?php foreach ($months as $month):
|
||||
$amount = $data[$month][$cat] ?? 0;
|
||||
?>
|
||||
<td class="text-center" style="padding:4px;">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
|
||||
<input type="number" step="0.01"
|
||||
class="prev-input <?= $ownerTextClass ?> cat-input-<?= $safeOwnerCls ?>-<?= $month ?>"
|
||||
style="width: 70px;"
|
||||
value="<?= $amount != 0 ? round($amount) : '' ?>"
|
||||
placeholder="-"
|
||||
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
|
||||
</div>
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<tr class="row-extres">
|
||||
<td class="sticky-col"><strong><?= tr('bud_sav_extra') ?></strong></td>
|
||||
<?php foreach ($months as $month):
|
||||
$total = $data[$month]['TOTAL_BANQUE'] ?? 0;
|
||||
$sum = 0;
|
||||
foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0);
|
||||
$extra = $total - $sum;
|
||||
?>
|
||||
<td class="text-center font-bold sum-target" id="extra_<?= $safeOwnerCls ?>_<?= $month ?>" style="color: <?= $extra >= 0 ? '#10b981' : '#ef4444' ?>; padding:12px;">
|
||||
<?= number_format($extra, 0, ',', ' ') ?> €
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="eg-row eg-total-row">
|
||||
<div class="eg-cell eg-sticky"><strong><?= tr('bud_sav_total_bank') ?></strong></div>
|
||||
<?php foreach ($months as $month):
|
||||
$val = $data[$month]['TOTAL_BANQUE'] ?? 0;
|
||||
?>
|
||||
<div class="eg-cell">
|
||||
<div class="eg-cell-content">
|
||||
<input type="number" step="0.01"
|
||||
class="prev-input eg-input eg-input-total total-input-<?= $safeOwnerCls ?>-<?= $month ?>"
|
||||
value="<?= $val != 0 ? round($val) : '' ?>"
|
||||
placeholder="0"
|
||||
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
|
||||
<span class="eg-currency-symbol">€</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php foreach ($allCategories as $cat): ?>
|
||||
<div class="eg-row">
|
||||
<div class="eg-cell eg-sticky"><?= htmlspecialchars($cat) ?></div>
|
||||
<?php foreach ($months as $month):
|
||||
$amount = $data[$month][$cat] ?? 0;
|
||||
?>
|
||||
<div class="eg-cell">
|
||||
<input type="number" step="0.01"
|
||||
class="prev-input eg-input cat-input-<?= $safeOwnerCls ?>-<?= $month ?>"
|
||||
value="<?= $amount != 0 ? round($amount) : '' ?>"
|
||||
placeholder="-"
|
||||
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="eg-row eg-extres-row">
|
||||
<div class="eg-cell eg-sticky"><strong><?= tr('bud_sav_extra') ?></strong></div>
|
||||
<?php foreach ($months as $month):
|
||||
$total = $data[$month]['TOTAL_BANQUE'] ?? 0;
|
||||
$sum = 0;
|
||||
foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0);
|
||||
$extra = $total - $sum;
|
||||
?>
|
||||
<div class="eg-cell font-bold sum-target <?= $extra >= 0 ? 'text-success' : 'text-danger' ?>" id="extra_<?= $safeOwnerCls ?>_<?= $month ?>">
|
||||
<?= number_format($extra, 0, ',', ' ') ?> €
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div id="savingsModal" class="pf-modal">
|
||||
<div class="pf-modal-content" style="max-width: 600px; width: 95%;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||
<h3 id="savingsModalTitle" class="pf-modal-title" style="margin:0;"><?= tr('bud_sav_modal_title_add') ?></h3>
|
||||
<button type="button" onclick="document.getElementById('savingsModal').style.display='none'; document.body.classList.remove('no-scroll');" style="border:none; background:none; font-size:1.8rem; cursor:pointer; color:#64748b; line-height:1;">×</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">×</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;">×</button>
|
||||
<button type="button" class="btn-remove-line" onclick="this.parentElement.remove()" title="${window.I18N['btn_delete']}">×</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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
// modules/budget/views/provisions.php
|
||||
|
||||
// --- RÉCUPÉRATION DES DÉPENSES PRÉVUES (MULTI-TENANT PDO) ---
|
||||
$stmt = $pdo->query("SELECT id, title, amount, expected_date FROM pf_expected_expenses WHERE is_paid = 0 ORDER BY expected_date ASC");
|
||||
$provisions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// --- RÉCUPÉRATION DES APPORTS ET DETTES (CLEARING) ---
|
||||
$currentYear = date('Y');
|
||||
|
||||
// 1. Apports de base (Eco Family) des parents configurés pour l'année
|
||||
$stmtConfig = $pdo->prepare("SELECT person, eco_family FROM pf_salary_config WHERE year = ?");
|
||||
$stmtConfig->execute([$currentYear]);
|
||||
$configs = $stmtConfig->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// 2. Dettes non résolues par personne (issues des Avances & Tricount)
|
||||
$stmtDebts = $pdo->query("SELECT payer, SUM(amount) as total_debt FROM pf_advances WHERE is_resolved = 0 GROUP BY payer");
|
||||
$debts = $stmtDebts->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
function formatDate($dateString) {
|
||||
if (!$dateString) return '';
|
||||
return date('d/m/Y', strtotime($dateString));
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="budget-view">
|
||||
<div class="view-header">
|
||||
<h2><?= tr('budget_provisions_title') ?></h2>
|
||||
|
||||
<button class="pf-btn pf-btn-primary" onclick="openOptimizeModal()">
|
||||
✨ <?= tr('btn_optimize_cashflow') ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pf-card">
|
||||
<h3 class="pf-card-h2"><?= tr('add_new_provision') ?></h3>
|
||||
|
||||
<form id="form-add-provision" data-action="/modules/budget/includes/api/add-provision.php">
|
||||
<div class="provisions-form-grid">
|
||||
|
||||
<div class="pf-form-group">
|
||||
<label class="pf-label" for="prov-title"><?= tr('provision_label') ?></label>
|
||||
<input type="text" id="prov-title" name="title" class="pf-input" placeholder="<?= tr('provision_placeholder_wood') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="pf-form-group">
|
||||
<label class="pf-label" for="prov-amount"><?= tr('amount') ?> (€)</label>
|
||||
<input type="number" id="prov-amount" name="amount" class="pf-input no-spinners input-amount-highlight" step="0.01" min="0.01" required>
|
||||
</div>
|
||||
|
||||
<div class="pf-form-group">
|
||||
<label class="pf-label" for="prov-date"><?= tr('expected_date') ?></label>
|
||||
<input type="date" id="prov-date" name="expected_date" class="pf-input" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="pf-btn pf-btn-secondary">
|
||||
<?= tr('btn_add') ?>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="budget-table-card table-responsive">
|
||||
<?php if (empty($provisions)): ?>
|
||||
<div class="optimization-hint" style="padding: 30px;">
|
||||
<p><?= tr('no_provisions') ?></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="pf-table savings-table provisions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= tr('provision_label') ?></th>
|
||||
<th style="width: 150px;"><?= tr('amount') ?></th>
|
||||
<th style="width: 150px;"><?= tr('expected_date') ?></th>
|
||||
<th style="width: 100px;"><?= tr('actions') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($provisions as $p): ?>
|
||||
<tr>
|
||||
<td class="prov-title-cell">
|
||||
<?= htmlspecialchars($p['title']) ?>
|
||||
</td>
|
||||
<td class="prov-amount-cell">
|
||||
<?= number_format($p['amount'], 2, ',', ' ') ?> €
|
||||
</td>
|
||||
<td class="prov-date-cell">
|
||||
<?= formatDate($p['expected_date']) ?>
|
||||
</td>
|
||||
<td class="text-right" style="padding-right: 15px;">
|
||||
<button class="btn-icon-action delete btn-safe-click" title="<?= tr('btn_delete') ?>" onclick="deleteProvision(<?= $p['id'] ?>)">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="optimizeModal" class="pf-modal">
|
||||
<div class="pf-modal-content provisions-modal-content">
|
||||
|
||||
<div class="pf-modal-header">
|
||||
<h3 class="pf-modal-title">🧠 <?= tr('optimization_assistant_title') ?></h3>
|
||||
<button type="button" class="pf-modal-close" onclick="closeOptimizeModal()">×</button>
|
||||
</div>
|
||||
|
||||
<form id="form-optimize" data-action="/modules/budget/includes/api/optimize-cashflow.php">
|
||||
<div id="dynamic-savings-inputs">
|
||||
<?php if (empty($configs)): ?>
|
||||
<p class="text-danger font-bold pf-muted-tiny"><?= tr('budget_opti_no_config') ?></p>
|
||||
<input type="number" step="0.01" min="0" name="savings[Global]" class="pf-input no-spinners" required>
|
||||
<?php else: ?>
|
||||
<?php foreach ($configs as $person => $ecoBase):
|
||||
$debt = $debts[$person] ?? 0;
|
||||
?>
|
||||
<div class="pf-form-group provision-person-card">
|
||||
<label class="pf-label provision-person-header">
|
||||
<strong class="text-main"><?= htmlspecialchars($person) ?></strong>
|
||||
<span class="pf-muted-note">
|
||||
<?php if ($debt > 0): ?>
|
||||
<span class="text-danger font-bold">(Dette en attente : <?= (float)$debt ?> €)</span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</label>
|
||||
<div class="provision-input-group">
|
||||
<input type="number" step="0.01" min="0" name="savings[<?= htmlspecialchars($person) ?>]" class="pf-input no-spinners input-amount-success" value="<?= (float)$ecoBase ?>" required>
|
||||
<span class="currency-symbol">€</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="pf-btn pf-btn-primary btn-block">
|
||||
<?= tr('calculate') ?>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="pf-divider">
|
||||
|
||||
<div id="optimization-results">
|
||||
<p class="optimization-hint">
|
||||
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
|
||||
window.I18N = {
|
||||
...(window.I18N || {}),
|
||||
'btn_delete': <?= json_encode(tr('btn_delete')) ?>,
|
||||
'error_generic': <?= json_encode(tr('error_generic')) ?>,
|
||||
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
|
||||
'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>
|
||||
};
|
||||
|
||||
// --- 2. GESTION DU FORMULAIRE D'AJOUT ---
|
||||
const formAdd = document.getElementById('form-add-provision');
|
||||
if (formAdd) {
|
||||
formAdd.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.innerText;
|
||||
submitBtn.innerText = '⏳';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const result = await pachaFetch(this.getAttribute('data-action'), {
|
||||
method: 'POST',
|
||||
body: new FormData(this)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(result.message || window.I18N['error_generic']);
|
||||
submitBtn.innerText = originalText;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
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>
|
||||
@@ -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): ?>
|
||||
|
||||
@@ -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,10 +221,70 @@ if (!defined('CURRENCY')) {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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"><?= tr('bud_settings_dyn_desc') ?></p>
|
||||
|
||||
<form id="form-dynamic-estimates" onsubmit="saveDynamicEstimates(event)">
|
||||
|
||||
<h5 class="bs-section-subtitle bordered"><?= tr('bud_dyn_sub_nanny') ?></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>
|
||||
|
||||
<!-- Ajustements ponctuels du CESU -->
|
||||
<div style="margin-top: 20px;">
|
||||
<div class="bs-cesu-header" style="display:flex; justify-content:space-between; margin-bottom:12px;">
|
||||
<label class="pf-label pf-m-0"><?= tr('bud_dyn_cesu_overrides') ?></label>
|
||||
<button type="button" class="pf-btn btn-secondary pf-btn-sm" onclick="addCesuOverride()">+ <?= tr('bud_dyn_cesu_add') ?></button>
|
||||
</div>
|
||||
<div id="cesu-overrides-list" class="bs-cesu-list"></div>
|
||||
<input type="hidden" name="budget_cesu_overrides" id="budget_cesu_overrides_input" value="{}">
|
||||
</div>
|
||||
|
||||
<h5 class="bs-section-subtitle bordered pf-mt-md"><?= tr('bud_dyn_sub_school') ?></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 pf-mt-md">
|
||||
<button type="submit" class="pf-btn btn-secondary">💾 <?= tr('btn_save') ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -250,16 +322,40 @@ function switchBsTab(tabId, btnEl) {
|
||||
}
|
||||
|
||||
async function loadBudgetSettingsData() {
|
||||
try {
|
||||
const response = await pachaFetch('/modules/budget/includes/api/settings.php?action=get_all', { method: 'GET' });
|
||||
if (!response.success) throw new Error(response.error || tr('error_occured'));
|
||||
renderAccounts(response.data.accounts);
|
||||
renderCategories(response.data.categories);
|
||||
try {
|
||||
const response = await pachaFetch('/modules/budget/includes/api/settings.php?action=get_all', { method: 'GET' });
|
||||
if (!response.success) throw new Error(response.error || tr('error_occured'));
|
||||
|
||||
// Alimenter la liste des propriétaires pour les comptes
|
||||
const ownerSelect = document.getElementById('select-account-owner');
|
||||
if (ownerSelect && response.data.people) {
|
||||
ownerSelect.innerHTML = '<option value="">Commun</option>';
|
||||
response.data.people.forEach(p => {
|
||||
ownerSelect.innerHTML += `<option value="${p.id}">${p.name}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
renderAccounts(response.data.accounts);
|
||||
renderCategories(response.data.categories);
|
||||
populateRuleCategories(response.data.categories);
|
||||
renderRules(response.data.rules);
|
||||
renderSalaries(response.data.salaries, response.data.year);
|
||||
const currencySelect = document.getElementById('bs-currency-select');
|
||||
if (currencySelect && response.data.currency) currencySelect.value = response.data.currency;
|
||||
|
||||
const bs = response.data.budget_settings || {};
|
||||
if (document.getElementById('budget_nanny_fixed')) document.getElementById('budget_nanny_fixed').value = bs.budget_nanny_fixed || 700;
|
||||
if (document.getElementById('budget_nanny_daily')) document.getElementById('budget_nanny_daily').value = bs.budget_nanny_daily || 4;
|
||||
if (document.getElementById('budget_nanny_aid')) document.getElementById('budget_nanny_aid').value = bs.budget_nanny_aid || 120;
|
||||
if (document.getElementById('budget_nanny_cesu_avg')) document.getElementById('budget_nanny_cesu_avg').value = bs.budget_nanny_cesu_avg || 170;
|
||||
if (document.getElementById('budget_school_meal')) document.getElementById('budget_school_meal').value = bs.budget_school_meal || 5.62;
|
||||
if (document.getElementById('budget_school_aftercare')) document.getElementById('budget_school_aftercare').value = bs.budget_school_aftercare || 1.97;
|
||||
if (document.getElementById('budget_school_fullday')) document.getElementById('budget_school_fullday').value = bs.budget_school_fullday || 21.89;
|
||||
|
||||
try { cesuOverrides = JSON.parse(bs.budget_cesu_overrides || '{}'); }
|
||||
catch (e) { cesuOverrides = {}; }
|
||||
renderCesuOverrides();
|
||||
|
||||
} 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 +420,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 +545,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 +613,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 +632,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 +652,99 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// --- LOGIQUE DES AJUSTEMENTS CESU ---
|
||||
let cesuOverrides = {};
|
||||
|
||||
function renderCesuOverrides() {
|
||||
const list = document.getElementById('cesu-overrides-list');
|
||||
const input = document.getElementById('budget_cesu_overrides_input');
|
||||
if (!list || !input) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
// Tri décroissant pour afficher le mois le plus lointain en haut
|
||||
const keys = Object.keys(cesuOverrides).sort().reverse();
|
||||
|
||||
if (keys.length === 0) {
|
||||
list.innerHTML = `<div class="pf-empty-dashed" style="margin:0; padding:12px;"><em class="pf-muted-tiny">${window.I18N['bud_dyn_cesu_empty'] || 'Aucun ajustement enregistré.'}</em></div>`;
|
||||
} else {
|
||||
keys.forEach(k => {
|
||||
const btnDelText = window.I18N['btn_delete'] || 'Supprimer';
|
||||
// UI : Design aéré type "Carte HouseHub" avec des inputs sans bordures agressives
|
||||
list.innerHTML += `
|
||||
<div style="display: flex; gap: 10px; align-items: center; background: var(--bg-panel); padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border-light); box-shadow: var(--shadow-sm);">
|
||||
<input type="month" class="pf-input" style="width: 150px; margin: 0; padding: 6px 10px;" value="${k}" onchange="updateCesuOverrideKey('${k}', this.value)">
|
||||
<input type="number" step="0.01" class="pf-input" style="flex: 1; margin: 0; padding: 6px 10px; text-align: right;" value="${cesuOverrides[k]}" onchange="updateCesuOverrideValue('${k}', this.value)">
|
||||
<span style="color: var(--text-muted); font-weight: 600; margin-right: 5px;">€</span>
|
||||
<button type="button" class="btn-icon-action delete" style="margin: 0; background: var(--bg-danger-soft); width: 32px; height: 32px;" onclick="removeCesuOverride('${k}')" title="${btnDelText}">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
input.value = JSON.stringify(cesuOverrides);
|
||||
}
|
||||
|
||||
function addCesuOverride() {
|
||||
const keys = Object.keys(cesuOverrides).sort();
|
||||
let nextDate = new Date();
|
||||
nextDate.setMonth(nextDate.getMonth() + 1); // Base : le mois prochain
|
||||
|
||||
// S'il y a déjà des mois dans la liste, on prend le plus lointain et on ajoute 1 mois
|
||||
if (keys.length > 0) {
|
||||
const lastKey = keys[keys.length - 1]; // ex: "2026-10"
|
||||
const parts = lastKey.split('-');
|
||||
// L'astuce : Le mois en JS commence à 0. Donc en lui passant "10", on pointe déjà sur le mois d'après (Novembre) !
|
||||
nextDate = new Date(parts[0], parseInt(parts[1], 10), 1);
|
||||
}
|
||||
|
||||
// Sécurité anti-boucle infinie
|
||||
let m = nextDate.getFullYear() + '-' + String(nextDate.getMonth() + 1).padStart(2, '0');
|
||||
let safety = 0;
|
||||
while (cesuOverrides[m] !== undefined && safety < 24) {
|
||||
nextDate.setMonth(nextDate.getMonth() + 1);
|
||||
m = nextDate.getFullYear() + '-' + String(nextDate.getMonth() + 1).padStart(2, '0');
|
||||
safety++;
|
||||
}
|
||||
|
||||
cesuOverrides[m] = 0;
|
||||
renderCesuOverrides();
|
||||
}
|
||||
|
||||
function updateCesuOverrideKey(oldK, newK) {
|
||||
if (oldK !== newK && newK) {
|
||||
cesuOverrides[newK] = cesuOverrides[oldK];
|
||||
delete cesuOverrides[oldK];
|
||||
renderCesuOverrides();
|
||||
}
|
||||
}
|
||||
|
||||
function updateCesuOverrideValue(k, val) {
|
||||
cesuOverrides[k] = parseFloat(val) || 0;
|
||||
renderCesuOverrides();
|
||||
}
|
||||
|
||||
function removeCesuOverride(k) {
|
||||
delete cesuOverrides[k];
|
||||
renderCesuOverrides();
|
||||
}
|
||||
</script>
|
||||
+291
-197
@@ -116,50 +116,117 @@ if (isset($_POST['action']) && $_POST['action'] === 'save_snapshot') {
|
||||
// ============================================================================
|
||||
$csvData = [];
|
||||
$showPreview = false;
|
||||
$importError = null;
|
||||
|
||||
if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) {
|
||||
$file = $_FILES['csv_file']['tmp_name'];
|
||||
$handle = fopen($file, "r");
|
||||
|
||||
$rules = [];
|
||||
try {
|
||||
$stmtRules = $pdo->query("SELECT keyword, category, budget_item_id FROM pf_import_rules");
|
||||
while ($r = $stmtRules->fetch(PDO::FETCH_ASSOC)) {
|
||||
$rules[$r['keyword']] = ['cat' => $r['category'], 'budget_item_id' => $r['budget_item_id']];
|
||||
}
|
||||
} catch(Exception $e){}
|
||||
|
||||
$existingRefs = []; try { $existingRefs = $pdo->query("SELECT import_ref FROM pf_expenses WHERE import_ref IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN); } catch(Exception $e){}
|
||||
fgetcsv($handle, 1000, ";", "\"", "\\");
|
||||
|
||||
while (($data = fgetcsv($handle, 1000, ";", "\"", "\\")) !== FALSE) {
|
||||
$rawDebit = $data[8] ?? ''; $rawCredit = $data[9] ?? '';
|
||||
$amount = 0; $isCredit = 0;
|
||||
if (!empty(trim($rawCredit))) { $amount = abs((float)str_replace(',', '.', str_replace(' ', '', $rawCredit))); $isCredit = 1; }
|
||||
elseif (!empty(trim($rawDebit))) { $amount = abs((float)str_replace(',', '.', str_replace(' ', '', $rawDebit))); }
|
||||
else continue;
|
||||
if (isset($_FILES['csv_file'])) {
|
||||
if ($_FILES['csv_file']['error'] == 0) {
|
||||
|
||||
$file = $_FILES['csv_file']['tmp_name'];
|
||||
|
||||
// 🔥 FIX INDESTRUCTIBLE PHP 8.3+ POUR LES FINS DE LIGNE iPHONE / MAC
|
||||
// On normalise brutalement tous les types de sauts de ligne en \n
|
||||
$fileContent = file_get_contents($file);
|
||||
$fileContent = preg_replace("/\r\n|\r|\n/", "\n", $fileContent);
|
||||
file_put_contents($file, $fileContent);
|
||||
|
||||
$dateParts = explode('/', $data[0]);
|
||||
$dateSql = (count($dateParts) == 3) ? $dateParts[2].'-'.$dateParts[1].'-'.$dateParts[0] : date('Y-m-d');
|
||||
$label = trim($data[1]) ?: trim($data[2]);
|
||||
$refCSV = trim($data[3]);
|
||||
$uniqueKey = !empty($refCSV) ? "REF_".$refCSV : "HASH_".md5($dateSql.$label.number_format($amount, 2).$isCredit);
|
||||
$isDuplicate = in_array($uniqueKey, $existingRefs);
|
||||
$handle = fopen($file, "r");
|
||||
|
||||
$suggestedCat = '';
|
||||
$suggestedItemId = null;
|
||||
foreach ($rules as $kw => $ruleData) {
|
||||
if (stripos($label, $kw) !== false) {
|
||||
$suggestedCat = $ruleData['cat'];
|
||||
$suggestedItemId = $ruleData['budget_item_id'];
|
||||
break;
|
||||
}
|
||||
// 1. Récupération dynamique du mapping configuré dans les paramètres
|
||||
$stmtMap = $pdo->query("SELECT csv_mapping FROM pf_foyer_settings LIMIT 1");
|
||||
$csvMapRaw = $stmtMap->fetchColumn();
|
||||
$mapping = $csvMapRaw ? json_decode($csvMapRaw, true) : null;
|
||||
|
||||
$delimiter = $mapping['delimiter'] ?? ';';
|
||||
$colDate = isset($mapping['col_date']) ? (int)$mapping['col_date'] : 0;
|
||||
$colLabel = isset($mapping['col_label']) ? (int)$mapping['col_label'] : 1;
|
||||
$amountType = $mapping['amount_type'] ?? 'split';
|
||||
$colDebit = isset($mapping['col_debit']) ? (int)$mapping['col_debit'] : 8;
|
||||
$colCredit = isset($mapping['col_credit']) ? (int)$mapping['col_credit'] : 9;
|
||||
$colRef = isset($mapping['col_ref']) ? (int)$mapping['col_ref'] : 3;
|
||||
$dateFormat = $mapping['date_format'] ?? 'd/m/Y';
|
||||
|
||||
$rules = [];
|
||||
try {
|
||||
$stmtRules = $pdo->query("SELECT keyword, category, budget_item_id FROM pf_import_rules");
|
||||
while ($r = $stmtRules->fetch(PDO::FETCH_ASSOC)) {
|
||||
$rules[$r['keyword']] = ['cat' => $r['category'], 'budget_item_id' => $r['budget_item_id']];
|
||||
}
|
||||
} catch(Exception $e){}
|
||||
|
||||
$existingRefs = [];
|
||||
try {
|
||||
$existingRefs = $pdo->query("SELECT import_ref FROM pf_expenses WHERE import_ref IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN);
|
||||
} catch(Exception $e){}
|
||||
|
||||
fgetcsv($handle, 1000, $delimiter, "\"", "\\"); // Skip header
|
||||
|
||||
while (($data = fgetcsv($handle, 1000, $delimiter, "\"", "\\")) !== FALSE) {
|
||||
// Si la ligne est vide on passe
|
||||
if (empty(array_filter($data))) continue;
|
||||
|
||||
$amount = 0;
|
||||
$isCredit = 0;
|
||||
|
||||
// 2. Traitement dynamique du montant
|
||||
if ($amountType === 'single') {
|
||||
$val = $data[$colDebit] ?? '';
|
||||
if (!empty(trim($val))) {
|
||||
$floatVal = (float)str_replace([' ', ','], ['', '.'], $val);
|
||||
$amount = abs($floatVal);
|
||||
$isCredit = $floatVal > 0 ? 1 : 0;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$rawDebit = $data[$colDebit] ?? '';
|
||||
$rawCredit = $data[$colCredit] ?? '';
|
||||
if (!empty(trim($rawCredit))) {
|
||||
$amount = abs((float)str_replace([' ', ','], ['', '.'], $rawCredit));
|
||||
$isCredit = 1;
|
||||
} elseif (!empty(trim($rawDebit))) {
|
||||
$amount = abs((float)str_replace([' ', ','], ['', '.'], $rawDebit));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Traitement dynamique de la date
|
||||
$rawDate = $data[$colDate] ?? '';
|
||||
$dateSql = date('Y-m-d');
|
||||
if ($dateFormat === 'd/m/Y') {
|
||||
$dateParts = explode('/', $rawDate);
|
||||
if (count($dateParts) >= 3) {
|
||||
$dateSql = $dateParts[2].'-'.$dateParts[1].'-'.$dateParts[0];
|
||||
}
|
||||
} else {
|
||||
$ts = strtotime($rawDate);
|
||||
if ($ts) $dateSql = date('Y-m-d', $ts);
|
||||
}
|
||||
|
||||
// On prend la colonne Label paramétrée, ou la 2ème en secours
|
||||
$label = trim($data[$colLabel] ?? '') ?: trim($data[2] ?? '');
|
||||
$refCSV = trim($data[$colRef] ?? '');
|
||||
$uniqueKey = !empty($refCSV) ? "REF_".$refCSV : "HASH_".md5($dateSql.$label.number_format($amount, 2).$isCredit);
|
||||
$isDuplicate = in_array($uniqueKey, $existingRefs);
|
||||
|
||||
$suggestedCat = '';
|
||||
$suggestedItemId = null;
|
||||
foreach ($rules as $kw => $ruleData) {
|
||||
if (stripos($label, $kw) !== false) {
|
||||
$suggestedCat = $ruleData['cat'];
|
||||
$suggestedItemId = $ruleData['budget_item_id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$csvData[] = ['date'=>$dateSql, 'label'=>$label, 'amount'=>$amount, 'cat'=>$suggestedCat, 'suggested_item_id'=>$suggestedItemId, 'ref'=>$uniqueKey, 'is_duplicate'=>$isDuplicate, 'is_credit'=>$isCredit];
|
||||
}
|
||||
|
||||
$csvData[] = ['date'=>$dateSql, 'label'=>$label, 'amount'=>$amount, 'cat'=>$suggestedCat, 'suggested_item_id'=>$suggestedItemId, 'ref'=>$uniqueKey, 'is_duplicate'=>$isDuplicate, 'is_credit'=>$isCredit];
|
||||
fclose($handle);
|
||||
$showPreview = true;
|
||||
} else {
|
||||
$importError = "Échec d'upload iOS/Serveur (Code : " . $_FILES['csv_file']['error'] . "). Si le fichier vient d'iCloud, vérifiez qu'il est bien téléchargé localement sur le téléphone.";
|
||||
$showPreview = true; //
|
||||
}
|
||||
fclose($handle);
|
||||
$showPreview = true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -238,11 +305,28 @@ $fixedChargesList = []; $incomeList = []; $pending_charges = [];
|
||||
// MAPPING DYNAMIQUE DES BUDGETS PRÉVISIONNELS (NOUVELLE LOGIQUE)
|
||||
// ============================================================================
|
||||
// On exclut "SAVINGS" pour s'aligner à 100% avec le recap.php
|
||||
$stmt = $pdo->query("SELECT id, name, amount, type, category, is_estimate, payment_day, mapping_keywords FROM pf_budget_items WHERE category != 'SAVINGS' ORDER BY name ASC");
|
||||
$stmt = $pdo->query("SELECT * FROM pf_budget_items WHERE category != 'SAVINGS' ORDER BY name ASC");
|
||||
$estimatesList = []; // On prépare la liste pour retenir nos estimations multi-catégories
|
||||
|
||||
while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$absAmount = 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 par rapport au mois actif du suivi
|
||||
$prevDate = date('Y-m-d', strtotime('-1 month', strtotime($viewMonthDate)));
|
||||
$calcYear = date('Y', strtotime($prevDate));
|
||||
$calcMonth = date('m', strtotime($prevDate));
|
||||
|
||||
$calc = new DynamicBudgetCalculator($pdo, $calcYear, $calcMonth);
|
||||
$estimateData = $calc->getEstimate($item['dynamic_code']);
|
||||
$absAmount = $estimateData['amount'];
|
||||
$calcDetails = $estimateData['details'];
|
||||
}
|
||||
// --------------------------------
|
||||
|
||||
$amt = ($item['type'] === 'Annuel') ? $absAmount / 12 : $absAmount;
|
||||
$name = trim($item['name']);
|
||||
$catCode = $item['category'];
|
||||
@@ -280,8 +364,9 @@ while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
}
|
||||
if (!$isPaid) {
|
||||
$reste_a_venir_calc += $absAmount;
|
||||
// Ajouté dans le tableau avec un tooltip vide
|
||||
$pending_charges[] = ['name' => $name, 'amount' => $absAmount, 'tooltip' => 'Charge fixe en attente'];
|
||||
// On inclut les détails de l'interception dans le tooltip natif du suivi
|
||||
$tooltip = !empty($calcDetails) ? $calcDetails : 'Charge fixe en attente';
|
||||
$pending_charges[] = ['name' => $name, 'amount' => $absAmount, 'tooltip' => $tooltip];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,12 +375,12 @@ while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$estimatesList[] = [
|
||||
'name' => $name,
|
||||
'amount' => $amt,
|
||||
'categories' => !empty($catCode) ? array_map('trim', explode(',', $catCode)) : []
|
||||
'categories' => !empty($catCode) ? array_map('trim', explode(',', $catCode)) : [],
|
||||
'calc_details' => $calcDetails // Transmission pour le second bloc
|
||||
];
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
$catCodesArray = array_map('trim', explode(',', $catCode));
|
||||
$firstCat = $catCodesArray[0];
|
||||
@@ -366,7 +451,10 @@ foreach ($estimatesList as $est) {
|
||||
$rem = max(0, $est['amount'] - $spentForEstimate);
|
||||
if ($rem > 0) {
|
||||
$reste_a_venir_calc += $rem;
|
||||
$tooltip = !empty($detailsHover) ? implode(' | ', $detailsHover) : 'Aucune dépense pour le moment';
|
||||
// On fusionne les détails du calcul dynamique (s'il y en a) avec les détails des dépenses réelles
|
||||
$baseTooltip = !empty($detailsHover) ? implode(' | ', $detailsHover) : 'Aucune dépense pour le moment';
|
||||
$tooltip = !empty($est['calc_details']) ? $est['calc_details'] . " | " . $baseTooltip : $baseTooltip;
|
||||
|
||||
$pending_charges[] = [
|
||||
'name' => 'Reste ' . $est['name'],
|
||||
'amount' => $rem,
|
||||
@@ -425,90 +513,90 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
|
||||
|
||||
<div class="budget-view">
|
||||
|
||||
<div style="background:white; padding:20px; border-radius:16px; box-shadow:var(--shadow-sm); margin-bottom:24px; border:1px solid #e2e8f0; position:relative;">
|
||||
<div class="bud-summary-card">
|
||||
|
||||
<?php if ($isClosed): ?>
|
||||
<div style="position:absolute; top:0; left:0; right:0; background:#f1f5f9; padding:5px; text-align:center; font-size:0.8rem; font-weight:bold; color:#64748b; border-radius:16px 16px 0 0; border-bottom:1px solid #e2e8f0;">
|
||||
<div class="bud-closed-banner">
|
||||
🔒 <?= tr('bud_closed_archived') ?>
|
||||
</div>
|
||||
<div style="height:20px;"></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px; margin-bottom: 20px;">
|
||||
<div style="display:flex; align-items:center; gap:15px;">
|
||||
<h2 style="margin:0; font-size:1.3rem; color:#0f172a; text-transform:capitalize;"><?= tr('bud_budget_of') ?> <?= $monthName ?></h2>
|
||||
<div class="bud-header-group">
|
||||
<div class="bud-header-title-wrapper">
|
||||
<h2 class="bud-header-title"><?= tr('bud_budget_of') ?> <?= $monthName ?></h2>
|
||||
</div>
|
||||
|
||||
<div class="action-toolbar" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||||
<?php if (!$isClosed): ?>
|
||||
<button type="button" class="pf-btn btn-secondary" onclick="openSuiviModal('importCsvModal')" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">
|
||||
📂 <?= tr('bud_import_csv') ?? 'Importer CSV' ?>
|
||||
</button>
|
||||
|
||||
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_close') ?>');">
|
||||
<input type="hidden" name="action" value="close_month">
|
||||
<input type="hidden" name="close_month_date" value="<?= $viewMonthDate ?>">
|
||||
<input type="hidden" name="freeze_solde_actuel" value="<?= $solde_actuel ?>">
|
||||
<input type="hidden" name="freeze_capacite_max" value="<?= $capacite_max ?>">
|
||||
<input type="hidden" name="freeze_solde_theorique" value="<?= $solde_theorique ?>">
|
||||
<input type="hidden" name="freeze_reste_a_venir" value="<?= $reste_a_venir ?>">
|
||||
<button type="submit" class="pf-btn" style="background:linear-gradient(135deg, #10b981, #059669); padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔒 <?= tr('bud_close_btn') ?></button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_reopen') ?>');">
|
||||
<input type="hidden" name="action" value="reopen_month">
|
||||
<button type="submit" class="pf-btn btn-secondary" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔓 <?= tr('bud_reopen_btn') ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="suivi-nav-group">
|
||||
<a href="<?= $prevLink ?>" class="suivi-btn-nav">◀</a>
|
||||
<a href="<?= $defaultLink ?>" class="suivi-btn-nav"><?= tr('bud_active_month_btn') ?></a>
|
||||
<a href="<?= $nextLink ?>" class="suivi-btn-nav">▶</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:15px; margin-bottom:25px;">
|
||||
<div style="padding:15px; background:#f8fafc; border-radius:12px; position:relative; border:1px solid #e2e8f0;">
|
||||
<div style="font-size:0.85rem; color:#64748b; margin-bottom:4px;"><?= tr('bud_bank_balance') ?> <?= $isClosed ? "(".tr('bud_frozen').")" : "(".tr('bud_current').")" ?></div>
|
||||
<div style="font-size:1.4rem; font-weight:700; color:#0f172a;">
|
||||
<?= number_format($solde_actuel, 2, ',', ' ') ?> €
|
||||
</div>
|
||||
<div class="action-toolbar bud-toolbar">
|
||||
<?php if (!$isClosed): ?>
|
||||
<button onclick="openSuiviModal('snapshotModal')" style="position:absolute; top:12px; right:12px; background:white; border:1px solid #cbd5e1; border-radius:6px; padding:4px 8px; cursor:pointer; font-size:0.8rem; color:#475569; transition:0.2s;" onmouseover="this.style.background='#f1f5f9'" onmouseout="this.style.background='white'">✏️ <?= tr('bud_update_btn') ?></button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="openSuiviModal('importCsvModal')">
|
||||
📂 <?= tr('bud_import_csv') ?? 'Importer CSV' ?>
|
||||
</button>
|
||||
|
||||
<div style="padding:15px; background:#f8fafc; border-radius:12px; border:1px solid #e2e8f0;">
|
||||
<div style="font-size:0.85rem; color:#64748b; margin-bottom:4px;"><?= tr('bud_theoretical_balance') ?></div>
|
||||
<div style="font-size:1.4rem; font-weight:700; color:<?= $solde_theorique < 0 ? '#ef4444' : '#334155' ?>;">
|
||||
<?= number_format($solde_theorique, 2, ',', ' ') ?> €
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding:15px; background:#fef2f2; border-radius:12px; border:1px solid #fecaca; position: relative;">
|
||||
<div style="font-size:0.85rem; color:#991b1b; margin-bottom:4px; display:flex; justify-content:space-between; align-items:center;">
|
||||
<span><?= tr('bud_upcoming_charges') ?></span>
|
||||
<button onclick="toggleDiv('pendingDetailsList')" style="background:none; border:none; cursor:pointer; font-size:1rem; padding:0; filter: grayscale(1); opacity: 0.7; transition: 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.7">👁️</button>
|
||||
</div>
|
||||
<div style="font-size:1.4rem; font-weight:700; color:#b91c1c;">
|
||||
- <?= number_format($reste_a_venir, 2, ',', ' ') ?> €
|
||||
</div>
|
||||
<div id="pendingDetailsList" style="display:none; margin-top: 12px; padding-top: 12px; border-top: 1px dashed #fca5a5; font-size: 0.8rem; color: #7f1d1d;">
|
||||
<?php if (empty($pending_charges)): ?>
|
||||
<div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div>
|
||||
<?php else: ?>
|
||||
<?php foreach($pending_charges as $pc): ?>
|
||||
<div style="display:flex; justify-content:space-between; margin-bottom:6px;" <?= !empty($pc['tooltip']) ? 'title="'.htmlspecialchars($pc['tooltip']).'" style="cursor:help;"' : '' ?>>
|
||||
<span><?= htmlspecialchars($pc['name']) ?></span>
|
||||
<strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_close') ?>');">
|
||||
<input type="hidden" name="action" value="close_month">
|
||||
<input type="hidden" name="close_month_date" value="<?= $viewMonthDate ?>">
|
||||
<input type="hidden" name="freeze_solde_actuel" value="<?= $solde_actuel ?>">
|
||||
<input type="hidden" name="freeze_capacite_max" value="<?= $capacite_max ?>">
|
||||
<input type="hidden" name="freeze_solde_theorique" value="<?= $solde_theorique ?>">
|
||||
<input type="hidden" name="freeze_reste_a_venir" value="<?= $reste_a_venir ?>">
|
||||
<button type="submit" class="pf-btn bud-btn-lock">🔒 <?= tr('bud_close_btn') ?></button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_reopen') ?>');">
|
||||
<input type="hidden" name="action" value="reopen_month">
|
||||
<button type="submit" class="pf-btn btn-secondary pf-btn-small">🔓 <?= tr('bud_reopen_btn') ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="suivi-nav-group">
|
||||
<a href="<?= $prevLink ?>" class="suivi-btn-nav">◀</a>
|
||||
<a href="<?= $defaultLink ?>" class="suivi-btn-nav"><?= tr('bud_active_month_btn') ?></a>
|
||||
<a href="<?= $nextLink ?>" class="suivi-btn-nav">▶</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bud-kpi-grid">
|
||||
<div class="bud-kpi-card">
|
||||
<div class="bud-kpi-label"><?= tr('bud_bank_balance') ?> <?= $isClosed ? "(".tr('bud_frozen').")" : "(".tr('bud_current').")" ?></div>
|
||||
<div class="bud-kpi-value">
|
||||
<?= number_format($solde_actuel, 2, ',', ' ') ?> €
|
||||
</div>
|
||||
<?php if (!$isClosed): ?>
|
||||
<button onclick="openSuiviModal('snapshotModal')" class="bud-kpi-edit-btn">✏️ <?= tr('bud_update_btn') ?></button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="bud-kpi-card">
|
||||
<div class="bud-kpi-label"><?= tr('bud_theoretical_balance') ?></div>
|
||||
<div class="bud-kpi-value <?= $solde_theorique < 0 ? 'bud-kpi-value--danger' : '' ?>">
|
||||
<?= number_format($solde_theorique, 2, ',', ' ') ?> €
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bud-kpi-card bud-kpi-card--danger">
|
||||
<div class="bud-kpi-label bud-kpi-label--danger">
|
||||
<span><?= tr('bud_upcoming_charges') ?></span>
|
||||
<button onclick="toggleDiv('pendingDetailsList')" class="bud-pending-toggle">👁️</button>
|
||||
</div>
|
||||
<div class="bud-kpi-value bud-kpi-value--danger">
|
||||
- <?= number_format($reste_a_venir, 2, ',', ' ') ?> €
|
||||
</div>
|
||||
<div id="pendingDetailsList" class="bud-pending-list" style="display:none;">
|
||||
<?php if (empty($pending_charges)): ?>
|
||||
<div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div>
|
||||
<?php else: ?>
|
||||
<?php foreach($pending_charges as $pc): ?>
|
||||
<div class="bud-pending-item" <?= !empty($pc['tooltip']) ? 'title="'.htmlspecialchars($pc['tooltip']).'" style="cursor:help;"' : '' ?>>
|
||||
<span><?= htmlspecialchars($pc['name']) ?></span>
|
||||
<strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// --- 1. CALCULS DE L'ÉCHELLE DYNAMIQUE ---
|
||||
@@ -534,92 +622,91 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
|
||||
}
|
||||
?>
|
||||
|
||||
<div style="margin-top: 65px; margin-bottom: 75px; position: relative; font-family: inherit;">
|
||||
<div class="bud-scale-wrapper">
|
||||
<div class="bud-scale-track">
|
||||
<?php if ($posZero > 0): ?>
|
||||
<div class="bud-scale-zero" style="width: <?= $posZero ?>%;"></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="position: relative; height: 24px; background: #e2e8f0; border-radius: 12px; width: 100%; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<?php if ($posZero > 0): ?>
|
||||
<div style="position: absolute; left: 0; width: <?= $posZero ?>%; height: 100%; background: #fef2f2; border-radius: 12px 0 0 12px;"></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="position: absolute; left: <?= $leftActuel ?>%; width: <?= $widthActuel ?>%; height: 100%; background: repeating-linear-gradient(45deg, #fbbf24, #fbbf24 10px, #f59e0b 10px, #f59e0b 20px); border-radius: <?= $solde_actuel >= 0 ? '0 12px 12px 0' : '12px' ?>; transition: width 0.5s ease-out;"></div>
|
||||
|
||||
<?php if ($solde_theorique < 0): ?>
|
||||
<div style="position: absolute; left: <?= $leftDanger ?>%; width: <?= $widthDanger ?>%; height: 100%; background: repeating-linear-gradient(45deg, #ef4444, #ef4444 10px, #dc2626 10px, #dc2626 20px); border-radius: 12px 0 0 12px; opacity: 0.95;"></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div 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 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 style="position: absolute; left: <?= $posTheorique ?>%; top: 24px; height: 30px; width: 3px; background: <?= $solde_theorique >= 0 ? '#8b5cf6' : '#ef4444' ?>; z-index: 12;"></div>
|
||||
<div style="position: absolute; left: <?= $posTheorique ?>%; top: 54px; transform: translateX(-50%); font-size: 0.85rem; font-weight: bold; color: <?= $solde_theorique >= 0 ? '#8b5cf6' : '#ef4444' ?>; white-space: nowrap;"><?= tr('bud_bar_theoretical') ?? 'Théorique' ?></div>
|
||||
|
||||
<?php
|
||||
$overlapMax = abs($posMax - $posActuel) < 35;
|
||||
$topMaxText = $overlapMax ? '-60px' : '-35px';
|
||||
$heightMaxLine = $overlapMax ? '65px' : '40px';
|
||||
$topMaxLine = $overlapMax ? '-40px' : '-15px';
|
||||
?>
|
||||
<div style="position: absolute; left: <?= $posMax ?>%; top: <?= $topMaxLine ?>; height: <?= $heightMaxLine ?>; width: 2px; background: #cbd5e1; z-index: 10;"></div>
|
||||
|
||||
<div style="position: absolute; left: <?= $posMax ?>%; top: <?= $topMaxText ?>; transform: translateX(calc(-100% + 10px)); font-size: 0.8rem; font-weight: bold; color: #64748b; white-space: nowrap; cursor: help;" title="<?= sprintf(tr('bud_capacity_tooltip'), number_format($solde_initial, 0), number_format($salaires_retenus, 0), number_format($rentrees_autres, 0)) ?>">
|
||||
<?= tr('bud_bar_max_cap') ?? 'Capacité Max' ?> : <?= number_format($capacite_max, 0, ',', ' ') ?> € ℹ️
|
||||
</div>
|
||||
<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 class="bud-scale-bar--danger" style="left: <?= $leftDanger ?>%; width: <?= $widthDanger ?>%;"></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="bud-scale-marker bud-scale-marker--zero" style="left: <?= $posZero ?>%;"></div>
|
||||
<div class="bud-scale-label bud-scale-label--zero" style="left: <?= $posZero ?>%;">0 €</div>
|
||||
|
||||
<div 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>
|
||||
|
||||
<?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;
|
||||
$topMaxText = $overlapMax ? '-60px' : '-35px';
|
||||
$heightMaxLine = $overlapMax ? '65px' : '40px';
|
||||
$topMaxLine = $overlapMax ? '-40px' : '-15px';
|
||||
?>
|
||||
<div class="bud-scale-marker bud-scale-marker--max" style="left: <?= $posMax ?>%; top: <?= $topMaxLine ?>; height: <?= $heightMaxLine ?>;"></div>
|
||||
|
||||
<div class="bud-scale-label bud-scale-label--max" style="left: <?= $posMax ?>%; top: <?= $topMaxText ?>; transform: translateX(calc(-100% + 10px));" title="<?= sprintf(tr('bud_capacity_tooltip'), number_format($solde_initial, 0), number_format($salaires_retenus, 0), number_format($rentrees_autres, 0)) ?>">
|
||||
<?= tr('bud_bar_max_cap') ?? 'Capacité Max' ?> : <?= number_format($capacite_max, 0, ',', ' ') ?> € ℹ️
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="categories-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:24px;">
|
||||
<?php foreach ($categoriesConfig as $key => $conf): ?>
|
||||
<div class="cat-card" <?= $isClosed ? 'style="opacity:0.8;"' : '' ?>>
|
||||
<div class="cat-card-header" style="background:<?= $conf['color'] ?>15; border-bottom:1px solid <?= $conf['color'] ?>30;">
|
||||
<div class="cat-card-title-group">
|
||||
<h3 style="margin:0; font-size:1rem; color:<?= $conf['color'] ?>;"><?= $conf['label'] ?></h3>
|
||||
<div class="cat-card-subtitle">
|
||||
<?php $logic = getDisplayLogic($totals[$key], $conf['budget'], $conf['type']); echo $logic['text']; ?>
|
||||
</div>
|
||||
<div class="bud-categories-grid">
|
||||
<?php foreach ($categoriesConfig as $key => $conf): ?>
|
||||
<div class="cat-card" <?= $isClosed ? 'style="opacity:0.8;"' : '' ?>>
|
||||
<div class="cat-card-header" style="background:<?= $conf['color'] ?>15; border-bottom:1px solid <?= $conf['color'] ?>30;">
|
||||
<div class="cat-card-title-group">
|
||||
<h3 style="margin:0; font-size:1rem; color:<?= $conf['color'] ?>;"><?= $conf['label'] ?></h3>
|
||||
<div class="cat-card-subtitle">
|
||||
<?php $logic = getDisplayLogic($totals[$key], $conf['budget'], $conf['type']); echo $logic['text']; ?>
|
||||
</div>
|
||||
<?php if (!$isClosed): ?>
|
||||
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes(strip_tags($conf['label'])) ?>')">+</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php $barCol = ($conf['db_type'] === 'Income') ? '#10b981' : ($logic['isOver'] ? '#ef4444' : $conf['color']); ?>
|
||||
<div style="background:#f1f5f9; height:4px; width:100%;">
|
||||
<div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1; max-height:300px; overflow-y:auto; padding:0;">
|
||||
<?php if (empty($expensesByCategory[$key])): ?>
|
||||
<div style="padding:20px; text-align:center; color:#cbd5e1; font-style:italic; font-size:0.85rem;"><?= tr('bud_no_lines') ?></div>
|
||||
<?php else: ?>
|
||||
<table style="width:100%; border-collapse:collapse; font-size:0.85rem;">
|
||||
<?php foreach ($expensesByCategory[$key] as $exp): ?>
|
||||
<tr style="border-bottom:1px solid #f8fafc;">
|
||||
<td style="padding:10px 15px; color:#94a3b8;"><?= date('d/m', strtotime($exp['date_exp'])) ?></td>
|
||||
<td style="padding:10px 5px; font-weight:500;">
|
||||
<?= htmlspecialchars($exp['label']) ?>
|
||||
</td>
|
||||
<td style="padding:10px 15px; text-align:right; font-weight:600; color:<?= $exp['amount'] > 0 ? '#10b981' : '#1e293b' ?>;">
|
||||
<?= $exp['amount'] > 0 ? '+' : '-' ?><?= number_format(abs($exp['amount']), 2) ?>
|
||||
</td>
|
||||
<?php if (!$isClosed): ?>
|
||||
<td style="width:70px; padding-right:10px; text-align:right; white-space:nowrap;">
|
||||
<button class="btn-icon-action edit" onclick='openEditModal(<?= json_encode($exp, JSON_HEX_APOS | JSON_HEX_QUOT) ?>)' title="<?= tr('edit') ?>">✏️</button>
|
||||
<button class="btn-icon-action delete btn-safe-click" onclick="deleteExpense(<?= $exp['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!$isClosed): ?>
|
||||
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes(strip_tags($conf['label'])) ?>')">+</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php $barCol = ($conf['db_type'] === 'Income') ? 'var(--success)' : ($logic['isOver'] ? 'var(--danger)' : $conf['color']); ?>
|
||||
<div style="background:var(--bg-page); height:4px; width:100%;">
|
||||
<div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1; max-height:300px; overflow-y:auto; padding:0;">
|
||||
<?php if (empty($expensesByCategory[$key])): ?>
|
||||
<div style="padding:20px; text-align:center; color:var(--text-muted); font-style:italic; font-size:0.85rem;"><?= tr('bud_no_lines') ?></div>
|
||||
<?php else: ?>
|
||||
<table class="bud-expense-table">
|
||||
<?php foreach ($expensesByCategory[$key] as $exp): ?>
|
||||
<tr class="bud-expense-row">
|
||||
<td class="bud-expense-date"><?= date('d/m', strtotime($exp['date_exp'])) ?></td>
|
||||
<td class="bud-expense-label">
|
||||
<?= htmlspecialchars($exp['label']) ?>
|
||||
</td>
|
||||
<td class="bud-expense-amount" style="color:<?= $exp['amount'] > 0 ? 'var(--success)' : 'var(--text-main)' ?>;">
|
||||
<?= $exp['amount'] > 0 ? '+' : '-' ?><?= number_format(abs($exp['amount']), 2) ?>
|
||||
</td>
|
||||
<?php if (!$isClosed): ?>
|
||||
<td class="bud-expense-actions">
|
||||
<button class="btn-icon-action edit" onclick='openEditModal(<?= json_encode($exp, JSON_HEX_APOS | JSON_HEX_QUOT) ?>)' title="<?= tr('edit') ?>">✏️</button>
|
||||
<button class="btn-icon-action delete btn-safe-click" onclick="deleteExpense(<?= $exp['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -730,8 +817,15 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
|
||||
<button type="button" onclick="closeSuiviModal('importCsvModal')" class="pf-modal-close" style="background:none; border:none; font-size:1.5rem; cursor:pointer;">×</button>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($importError)): ?>
|
||||
<div class="pf-alert pf-alert--error" style="margin-bottom: 15px;">
|
||||
<?= htmlspecialchars($importError) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$showPreview): ?>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<!-- L'action sécurise le fait qu'on poste bien sur le mois et la vue actuels même sur mobile -->
|
||||
<form method="POST" enctype="multipart/form-data" action="?tab=suivi&m=<?= htmlspecialchars($viewM) ?>&y=<?= htmlspecialchars($viewY) ?>">
|
||||
<div class="import-dropzone">
|
||||
<div style="font-size: 2.5rem; margin-bottom: 10px;">📄</div>
|
||||
<label class="pf-label"><?= tr('bud_csv_file') ?></label>
|
||||
@@ -743,7 +837,7 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="POST" id="formMapping">
|
||||
<form method="POST" id="formMapping" action="?tab=suivi&m=<?= htmlspecialchars($viewM) ?>&y=<?= htmlspecialchars($viewY) ?>">
|
||||
<input type="hidden" name="action" value="save_import">
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; flex-wrap:wrap; gap:10px;">
|
||||
@@ -1021,11 +1115,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;
|
||||
|
||||
@@ -10,15 +10,26 @@
|
||||
#f1f5f9 8px,
|
||||
#f1f5f9 16px
|
||||
);
|
||||
--c-off-carole: #fef3c7;
|
||||
--c-extra-off: #fee2e2;
|
||||
--c-selected: #dbeafe;
|
||||
|
||||
/* Thèmes Parents (Sync Budget) */
|
||||
--bg-alex: #ecfeff;
|
||||
--text-alex: #0891b2;
|
||||
--bg-laia: #fffbeb;
|
||||
--text-laia: #d97706;
|
||||
/* Palette dynamique pour les congés intervenants */
|
||||
--fc-palette-0: #fef3c7;
|
||||
--fc-palette-text-0: #b45309;
|
||||
--fc-palette-1: #fee2e2;
|
||||
--fc-palette-text-1: #b91c1c;
|
||||
--fc-palette-2: #dbeafe;
|
||||
--fc-palette-text-2: #1d4ed8;
|
||||
--fc-palette-3: #dcfce7;
|
||||
--fc-palette-text-3: #15803d;
|
||||
--fc-palette-4: #f3e8ff;
|
||||
--fc-palette-text-4: #7e22ce;
|
||||
--fc-palette-5: #ffedd5;
|
||||
--fc-palette-text-5: #c2410c;
|
||||
--fc-palette-6: #ccfbf1;
|
||||
--fc-palette-text-6: #0f766e;
|
||||
--fc-palette-7: #fce7f3;
|
||||
--fc-palette-text-7: #be185d;
|
||||
|
||||
--c-selected: #dbeafe;
|
||||
|
||||
/* Rappel des variables Global Design System pour cohérence */
|
||||
--pf-primary: #3b82f6;
|
||||
@@ -301,11 +312,30 @@
|
||||
background: var(--c-public-holiday) !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
.fc-day--off-carole {
|
||||
background: var(--c-off-carole) !important;
|
||||
|
||||
.fc-bg-palette-0 {
|
||||
background: var(--fc-palette-0) !important;
|
||||
}
|
||||
.fc-day--extra-off-carole {
|
||||
background: var(--c-extra-off) !important;
|
||||
.fc-bg-palette-1 {
|
||||
background: var(--fc-palette-1) !important;
|
||||
}
|
||||
.fc-bg-palette-2 {
|
||||
background: var(--fc-palette-2) !important;
|
||||
}
|
||||
.fc-bg-palette-3 {
|
||||
background: var(--fc-palette-3) !important;
|
||||
}
|
||||
.fc-bg-palette-4 {
|
||||
background: var(--fc-palette-4) !important;
|
||||
}
|
||||
.fc-bg-palette-5 {
|
||||
background: var(--fc-palette-5) !important;
|
||||
}
|
||||
.fc-bg-palette-6 {
|
||||
background: var(--fc-palette-6) !important;
|
||||
}
|
||||
.fc-bg-palette-7 {
|
||||
background: var(--fc-palette-7) !important;
|
||||
}
|
||||
|
||||
.fc-day--selected {
|
||||
@@ -314,8 +344,14 @@
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.fc-day--off-carole.fc-day--school-holiday,
|
||||
.fc-day--extra-off-carole.fc-day--school-holiday,
|
||||
.fc-bg-palette-0.fc-day--school-holiday,
|
||||
.fc-bg-palette-1.fc-day--school-holiday,
|
||||
.fc-bg-palette-2.fc-day--school-holiday,
|
||||
.fc-bg-palette-3.fc-day--school-holiday,
|
||||
.fc-bg-palette-4.fc-day--school-holiday,
|
||||
.fc-bg-palette-5.fc-day--school-holiday,
|
||||
.fc-bg-palette-6.fc-day--school-holiday,
|
||||
.fc-bg-palette-7.fc-day--school-holiday,
|
||||
.fc-day--centre.fc-day--school-holiday,
|
||||
.fc-day--avis.fc-day--school-holiday {
|
||||
box-shadow: inset 0 -8px 0 0 var(--c-school-holiday) !important;
|
||||
@@ -409,15 +445,23 @@
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.col-alex.header-group,
|
||||
.col-alex-sub {
|
||||
background: var(--bg-alex) !important;
|
||||
color: var(--text-alex) !important;
|
||||
.col-p1.header-group,
|
||||
.col-p1-sub {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--p1-main, #0891b2) 8%,
|
||||
#ffffff
|
||||
) !important;
|
||||
color: color-mix(in srgb, var(--p1-main, #0891b2) 85%, #000000) !important;
|
||||
}
|
||||
.col-laia.header-group,
|
||||
.col-laia-sub {
|
||||
background: var(--bg-laia) !important;
|
||||
color: var(--text-laia) !important;
|
||||
.col-p2.header-group,
|
||||
.col-p2-sub {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--p2-main, #f59e0b) 8%,
|
||||
#ffffff
|
||||
) !important;
|
||||
color: color-mix(in srgb, var(--p2-main, #f59e0b) 85%, #000000) !important;
|
||||
}
|
||||
|
||||
#planningTable tbody td {
|
||||
@@ -448,10 +492,10 @@ td.col-day {
|
||||
}
|
||||
th.col-total,
|
||||
td.col-total,
|
||||
th.col-alex-sub,
|
||||
td.col-alex-sub,
|
||||
th.col-laia-sub,
|
||||
td.col-laia-sub {
|
||||
th.col-p1-sub,
|
||||
td.col-p1-sub,
|
||||
th.col-p2-sub,
|
||||
td.col-p2-sub {
|
||||
width: 32px !important;
|
||||
min-width: 32px !important;
|
||||
max-width: 32px !important;
|
||||
@@ -481,7 +525,7 @@ td.col-laia-sub {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
padding: 4px; /* Réduit les marges internes */
|
||||
padding-bottom: 10px; /* Réduit les marges internes */
|
||||
}
|
||||
|
||||
/* 3. On s'assure que le tableau utilise tout l'espace disponible intelligemment */
|
||||
@@ -536,7 +580,7 @@ td.col-laia-sub {
|
||||
#planningTable tbody td.col-sticky-sem {
|
||||
writing-mode: horizontal-tb;
|
||||
transform: none;
|
||||
padding: 6px !important;
|
||||
padding-bottom: 10px !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -594,12 +638,7 @@ td.col-laia-sub {
|
||||
.fc-legend-public-holiday {
|
||||
background: var(--c-public-holiday);
|
||||
}
|
||||
.fc-legend-off-carole {
|
||||
background: var(--c-off-carole);
|
||||
}
|
||||
.fc-legend-extra-off-carole {
|
||||
background: var(--c-extra-off);
|
||||
}
|
||||
|
||||
.fc-legend-centre::after {
|
||||
content: "🏫";
|
||||
}
|
||||
@@ -660,27 +699,32 @@ td.col-laia-sub {
|
||||
#selectionMenu,
|
||||
#fc-month-selectionMenu,
|
||||
.fc-selection-menu {
|
||||
position: absolute;
|
||||
z-index: 9000;
|
||||
position: fixed !important;
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
z-index: 9999;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2) !important;
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
min-width: 210px;
|
||||
padding: 16px;
|
||||
width: 90%;
|
||||
max-width: 320px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
animation: menuPopIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
transform-origin: top center;
|
||||
animation: menuPopCenter 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards !important;
|
||||
}
|
||||
@keyframes menuPopIn {
|
||||
@keyframes menuPopCenter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
transform: translate(-50%, -45%) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,30 +961,6 @@ td.col-laia-sub {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#selectionMenu,
|
||||
#fc-month-selectionMenu,
|
||||
.fc-selection-menu {
|
||||
position: fixed !important;
|
||||
top: auto !important;
|
||||
left: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100% !important;
|
||||
border-radius: 24px 24px 0 0;
|
||||
padding: 24px 20px;
|
||||
transform: translateY(100%);
|
||||
animation: slideUpSheet 0.3s forwards;
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
#selectionMenu::before,
|
||||
#fc-month-selectionMenu::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 5px;
|
||||
background: #cbd5e1;
|
||||
border-radius: 10px;
|
||||
margin: 0 auto 15px auto;
|
||||
}
|
||||
.fc-two-months-container,
|
||||
.fc-three-months-container {
|
||||
display: flex !important;
|
||||
@@ -977,7 +997,6 @@ td.col-laia-sub {
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
/* 3. CORRECTION CRITIQUE : Le décalage "Sticky" de la colonne Semaine */
|
||||
/* Puisque la colonne Mois fait 35px, la colonne Semaine doit démarrer à 35px (et non plus 54px) */
|
||||
#planningTable tbody td.col-sticky-sem,
|
||||
#planningTable thead tr th.col-sticky-sem {
|
||||
@@ -1037,3 +1056,112 @@ td.col-laia-sub {
|
||||
#planningTable thead tr:nth-child(3) th {
|
||||
top: 64px !important;
|
||||
}
|
||||
|
||||
#planningTable th.rotated-text {
|
||||
height: 120px; /* Augmente la hauteur pour laisser respirer le texte */
|
||||
vertical-align: bottom;
|
||||
padding-bottom: 10px !important;
|
||||
}
|
||||
.rotated-text span {
|
||||
white-space: nowrap; /* Force le texte sur une seule ligne */
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* --- RÉCAPITULATIF INTERVENANT (DASHBOARD) --- */
|
||||
.fc-recap-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fc-recap-custom-dates {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
background: var(--pf-bg-lighter);
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--pf-border);
|
||||
}
|
||||
.fc-recap-kpi-container {
|
||||
background: var(--pf-bg-lighter);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--pf-border);
|
||||
padding: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.fc-recap-kpi-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--pf-border);
|
||||
}
|
||||
.fc-recap-kpi-main-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.fc-recap-kpi-main-val {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
color: var(--pf-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
.fc-recap-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.fc-recap-kpi-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--pf-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
box-shadow: var(--pf-shadow-sm);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.fc-recap-kpi-card:hover {
|
||||
border-color: var(--warning);
|
||||
}
|
||||
.fc-recap-kpi-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.fc-recap-kpi-val {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--warning);
|
||||
line-height: 1;
|
||||
}
|
||||
.fc-recap-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.fc-recap-error {
|
||||
text-align: center;
|
||||
color: var(--danger);
|
||||
font-weight: bold;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-recap-custom-dates,
|
||||
[data-theme="dark"] .fc-recap-kpi-container {
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
[data-theme="dark"] .fc-recap-kpi-card {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,7 +15,7 @@ try {
|
||||
$stmtPeople = $pdo->query("SELECT id, name, role, care_modes, color FROM pf_people WHERE is_active = 1 ORDER BY role ASC, name ASC");
|
||||
$people = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$stmtLeaves = $pdo->query("SELECT person_id, leave_type, anniversary_date, method, allowance FROM pf_person_leave_meta");
|
||||
$stmtLeaves = $pdo->query("SELECT person_id, leave_type, anniversary_date, method, allowance, carry_over_max_days, carry_over_deadline_month FROM pf_person_leave_meta");
|
||||
$leavesRaw = $stmtLeaves->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$leavesMap = [];
|
||||
@@ -24,7 +24,9 @@ try {
|
||||
'type' => $leave['leave_type'],
|
||||
'method' => $leave['method'] ?? 'FIXED',
|
||||
'allowance' => $leave['allowance'] ?? 0,
|
||||
'date' => $leave['anniversary_date']
|
||||
'date' => $leave['anniversary_date'],
|
||||
'carry_over_max_days' => $leave['carry_over_max_days'] ?? 0,
|
||||
'carry_over_deadline_month' => $leave['carry_over_deadline_month'] ?? 12
|
||||
];
|
||||
}
|
||||
|
||||
@@ -37,8 +39,8 @@ try {
|
||||
'calendar_working_hours' => $calendarSettingsRaw['calendar_working_hours'] ?? '08:00-19:00'
|
||||
];
|
||||
|
||||
// Catalogue des congés
|
||||
$stmtLeaveTypes = $pdo->query("SELECT code, label, default_allowance, reset_month, allow_carry_over FROM pf_leave_types ORDER BY label ASC");
|
||||
// Catalogue des congés (Nettoyé des règles de report qui sont maintenant sur la personne)
|
||||
$stmtLeaveTypes = $pdo->query("SELECT code, label, default_allowance, reset_month FROM pf_leave_types ORDER BY label ASC");
|
||||
$leaveTypes = $stmtLeaveTypes->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo json_encode([
|
||||
@@ -97,20 +99,22 @@ try {
|
||||
}
|
||||
|
||||
if ($action === 'save_leave_type') {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception(tr('error_occured'));
|
||||
|
||||
$mode = $_POST['mode'] ?? 'add';
|
||||
$code = strtoupper(trim($_POST['code'] ?? ''));
|
||||
$label = trim($_POST['label'] ?? '');
|
||||
$carryMax = (float)($_POST['carry_over_max_days'] ?? 0);
|
||||
$carryDeadline = (int)($_POST['carry_over_deadline_month'] ?? 12);
|
||||
|
||||
if (empty($code) || empty($label)) throw new Exception("Le code et le label sont obligatoires.");
|
||||
if (empty($code) || empty($label)) throw new Exception(tr('fc_err_code_label'));
|
||||
|
||||
if ($mode === 'add') {
|
||||
$stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label) VALUES (?, ?)");
|
||||
$stmt->execute([$code, $label]);
|
||||
$stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label, carry_over_max_days, carry_over_deadline_month) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$code, $label, $carryMax, $carryDeadline]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ? WHERE code = ?");
|
||||
$stmt->execute([$label, $code]);
|
||||
$stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ?, carry_over_max_days = ?, carry_over_deadline_month = ? WHERE code = ?");
|
||||
$stmt->execute([$label, $carryMax, $carryDeadline, $code]);
|
||||
}
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
@@ -141,7 +145,7 @@ try {
|
||||
// ─── 5. GESTION DES CONGÉS INDIVIDUELS (MEMBRES) ───
|
||||
if ($action === 'get_person_leaves') {
|
||||
$personId = (int)($_GET['person_id'] ?? 0);
|
||||
$stmt = $pdo->prepare("SELECT id, leave_type, allowance, method, anniversary_date FROM pf_person_leave_meta WHERE person_id = ? ORDER BY leave_type ASC");
|
||||
$stmt = $pdo->prepare("SELECT id, leave_type, allowance, method, anniversary_date, carry_over_max_days, carry_over_deadline_month FROM pf_person_leave_meta WHERE person_id = ? ORDER BY leave_type ASC");
|
||||
$stmt->execute([$personId]);
|
||||
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
|
||||
exit;
|
||||
@@ -150,25 +154,35 @@ try {
|
||||
if ($action === 'add_person_leave') {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
|
||||
|
||||
$id = (int)($_POST['meta_id'] ?? 0); // Nouvel ID pour différencier l'Update de l'Insert
|
||||
$personId = (int)($_POST['person_id'] ?? 0);
|
||||
$type = strtoupper(trim($_POST['leave_type'] ?? ''));
|
||||
$allowance = (float)($_POST['allowance'] ?? 0);
|
||||
$resetMonth = (int)($_POST['reset_month'] ?? 1);
|
||||
$carryMax = (float)($_POST['carry_over_max_days'] ?? 0);
|
||||
$carryDeadline = (int)($_POST['carry_over_deadline_month'] ?? 12);
|
||||
|
||||
$method = in_array($_POST['method'] ?? '', ['FIXED', 'ACCUMULATED']) ? $_POST['method'] : 'FIXED';
|
||||
|
||||
if ($personId <= 0 || empty($type)) throw new Exception("Données invalides.");
|
||||
|
||||
// Vérification des doublons
|
||||
$check = $pdo->prepare("SELECT id FROM pf_person_leave_meta WHERE person_id = ? AND leave_type = ?");
|
||||
$check->execute([$personId, $type]);
|
||||
if ($check->rowCount() > 0) throw new Exception("Ce congé est déjà attribué à cette personne.");
|
||||
|
||||
// On construit la date anniversaire avec le mois choisi par l'utilisateur
|
||||
$anniversaryDate = "2000-" . str_pad((string)$resetMonth, 2, "0", STR_PAD_LEFT) . "-01";
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO pf_person_leave_meta (person_id, leave_type, allowance, method, anniversary_date) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$personId, $type, $allowance, $method, $anniversaryDate]);
|
||||
if ($id > 0) {
|
||||
// ✏️ UPDATE (Modification)
|
||||
$stmt = $pdo->prepare("UPDATE pf_person_leave_meta SET allowance = ?, method = ?, anniversary_date = ?, carry_over_max_days = ?, carry_over_deadline_month = ? WHERE id = ? AND person_id = ?");
|
||||
$stmt->execute([$allowance, $method, $anniversaryDate, $carryMax, $carryDeadline, $id, $personId]);
|
||||
} else {
|
||||
// ➕ INSERT (Ajout)
|
||||
// Vérification des doublons
|
||||
$check = $pdo->prepare("SELECT id FROM pf_person_leave_meta WHERE person_id = ? AND leave_type = ?");
|
||||
$check->execute([$personId, $type]);
|
||||
if ($check->rowCount() > 0) throw new Exception("Ce congé est déjà attribué à cette personne.");
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO pf_person_leave_meta (person_id, leave_type, allowance, method, anniversary_date, carry_over_max_days, carry_over_deadline_month) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$personId, $type, $allowance, $method, $anniversaryDate, $carryMax, $carryDeadline]);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
|
||||
@@ -4,7 +4,7 @@ require __DIR__ . '/../../../../includes/db.php';
|
||||
|
||||
try {
|
||||
$stmt = $pdo->query("
|
||||
SELECT person_id, leave_type, snapshot_date, remaining_balance
|
||||
SELECT person_id, leave_type, snapshot_date, remaining_balance, carry_over_balance
|
||||
FROM pf_leave_snapshots
|
||||
ORDER BY snapshot_date ASC
|
||||
");
|
||||
|
||||
@@ -15,13 +15,16 @@ try {
|
||||
$stmtDel = $pdo->prepare("DELETE FROM pf_leave_snapshots WHERE person_id = ? AND leave_type = ? AND snapshot_date = ?");
|
||||
$stmtDel->execute([$input['person_id'], $input['leave_type'], $input['snapshot_date']]);
|
||||
|
||||
$carry_over_balance = isset($input['carry_over_balance']) ? (float)$input['carry_over_balance'] : 0.00;
|
||||
|
||||
// Étape 2 : On insère le nouveau solde
|
||||
$stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance) VALUES (?, ?, ?, ?)");
|
||||
$stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance, carry_over_balance) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([
|
||||
$input['person_id'],
|
||||
$input['leave_type'],
|
||||
$input['snapshot_date'],
|
||||
$input['remaining_balance']
|
||||
$input['remaining_balance'],
|
||||
$carry_over_balance
|
||||
]);
|
||||
|
||||
echo json_encode(['status' => 'success']);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
// modules/food/includes/api/api-liste.php
|
||||
ob_start();
|
||||
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||
require_once dirname(__DIR__, 4) . '/includes/auth.php';
|
||||
require_login();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
@@ -10,7 +11,7 @@ set_exception_handler(function (\Throwable $e) {
|
||||
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
|
||||
});
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||
require_once dirname(__DIR__, 4) . '/includes/db.php';
|
||||
|
||||
// ── Auto-create tables ────────────────────────────────────────────────────────
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
|
||||
@@ -96,11 +97,9 @@ function liste_ensure_default(PDO $pdo): int {
|
||||
return (int)$pdo->query("SELECT id FROM pf_lists ORDER BY position, id LIMIT 1")->fetchColumn();
|
||||
}
|
||||
|
||||
// Built-in keyword → category position mapping (position maps to seeded order above)
|
||||
function liste_builtin_detect(string $normalized): ?int {
|
||||
static $map = null;
|
||||
if ($map === null) $map = [
|
||||
// 1 = Fruits & Légumes
|
||||
'pomme'=>1,'poire'=>1,'banane'=>1,'orange'=>1,'citron'=>1,'raisin'=>1,
|
||||
'fraise'=>1,'framboise'=>1,'myrtille'=>1,'cerise'=>1,'peche'=>1,'abricot'=>1,
|
||||
'mangue'=>1,'kiwi'=>1,'ananas'=>1,'melon'=>1,'pasteque'=>1,'prune'=>1,
|
||||
@@ -113,120 +112,96 @@ function liste_builtin_detect(string $normalized): ?int {
|
||||
'gingembre'=>1,'avocat'=>1,'endive'=>1,'ciboulette'=>1,'persil'=>1,
|
||||
'basilic'=>1,'menthe'=>1,'thym'=>1,'romarin'=>1,'coriandre'=>1,
|
||||
'girofle'=>1,'citron vert'=>1,'lime'=>1,'pamplemousse'=>1,'litchi'=>1,
|
||||
// 2 = Viande
|
||||
'boeuf'=>2,'veau'=>2,'poulet'=>2,'porc'=>2,'agneau'=>2,'dinde'=>2,
|
||||
'lapin'=>2,'jambon'=>2,'lardon'=>2,'saucisse'=>2,'saucisson'=>2,
|
||||
'merguez'=>2,'chipolata'=>2,'steak'=>2,'escalope'=>2,'roti'=>2,
|
||||
'gigot'=>2,'filet'=>2,'cuisses'=>2,'viande hachee'=>2,'andouille'=>2,
|
||||
'magret'=>2,'canard'=>2,'foie'=>2,'boudin'=>2,'paupiette'=>2,
|
||||
'entrecote'=>2,'cote de boeuf'=>2,'onglet'=>2,'bavette'=>2,
|
||||
// 3 = Poissonnerie
|
||||
'saumon'=>3,'thon'=>3,'dorade'=>3,'bar'=>3,'cabillaud'=>3,'sole'=>3,
|
||||
'truite'=>3,'lieu'=>3,'maquereau'=>3,'sardine'=>3,'anchois'=>3,
|
||||
'crevette'=>3,'moule'=>3,'huitre'=>3,'coquille'=>3,'poulpe'=>3,
|
||||
'seiche'=>3,'langoustine'=>3,'lotte'=>3,'merlu'=>3,'colin'=>3,
|
||||
'tilapia'=>3,'daurade'=>3,'aiglefin'=>3,'homard'=>3,'crabe'=>3,
|
||||
// 4 = Boulangerie
|
||||
'pain'=>4,'baguette'=>4,'brioche'=>4,'croissant'=>4,'pain de mie'=>4,
|
||||
'ficelle'=>4,'fougasse'=>4,'ciabatta'=>4,'biscotte'=>4,'naan'=>4,
|
||||
'pita'=>4,'toast'=>4,'bagel'=>4,'pain burger'=>4,'wrap'=>4,
|
||||
// 5 = Frais
|
||||
'lait'=>5,'creme'=>5,'yaourt'=>5,'yourt'=>5,'beurre'=>5,
|
||||
'fromage'=>5,'camembert'=>5,'brie'=>5,'comte'=>5,'gruyere'=>5,
|
||||
'emmental'=>5,'mozzarella'=>5,'ricotta'=>5,'mascarpone'=>5,
|
||||
'feta'=>5,'chevre'=>5,'oeuf'=>5,'oeufs'=>5,'creme fraiche'=>5,
|
||||
'fromage blanc'=>5,'petit suisse'=>5,'kefir'=>5,
|
||||
// 6 = Surgelés
|
||||
'surgele'=>6,'pizza surgelee'=>6,'frite surgelee'=>6,'poisson pane'=>6,
|
||||
'legume surgele'=>6,'glace'=>6,'sorbet'=>6,'plat surgele'=>6,
|
||||
'nugget'=>6,'edamame'=>6,'petits pois'=>6,'epinards'=>6,
|
||||
// 7 = Boissons
|
||||
'eau'=>7,'coca'=>7,'cola'=>7,'limonade'=>7,'sirop'=>7,
|
||||
'biere'=>7,'vin'=>7,'champagne'=>7,'cafe'=>7,'the'=>7,
|
||||
'tisane'=>7,'soda'=>7,'jus'=>7,'kombucha'=>7,'cidre'=>7,
|
||||
'whisky'=>7,'rhum'=>7,'vodka'=>7,'prosecco'=>7,'rose'=>7,
|
||||
// 8 = Pâtes, Riz, Féculents
|
||||
'pate'=>8,'spaghetti'=>8,'tagliatelle'=>8,'penne'=>8,'fusilli'=>8,
|
||||
'macaroni'=>8,'riz'=>8,'couscous'=>8,'quinoa'=>8,'lentille'=>8,
|
||||
'pois chiche'=>8,'farine'=>8,'semoule'=>8,'polenta'=>8,'boulgour'=>8,
|
||||
'orge'=>8,'feculent'=>8,'vermicelle'=>8,'nouille'=>8,'gnocchi'=>8,
|
||||
// 9 = Épicerie salée
|
||||
'chips'=>9,'crackers'=>9,'olives'=>9,'cornichon'=>9,'capres'=>9,
|
||||
'tapenade'=>9,'houmous'=>9,'tzatziki'=>9,'sel'=>9,'poivre'=>9,
|
||||
'epice'=>9,'cube bouillon'=>9,'bouillon'=>9,'noix de cajou'=>9,
|
||||
'amande'=>9,'pistache'=>9,'noix'=>9,'cacahuete'=>9,
|
||||
// 10 = Conserves
|
||||
'conserve'=>10,'boite de tomate'=>10,'concentre de tomate'=>10,
|
||||
'mais'=>10,'ratatouille'=>10,'cassoulet'=>10,'pate de foie'=>10,
|
||||
'sardine boite'=>10,'thon boite'=>10,'maquereau boite'=>10,
|
||||
'soupe boite'=>10,'haricot boite'=>10,
|
||||
// 11 = Plats cuisinés
|
||||
'quiche'=>11,'lasagne'=>11,'gratin'=>11,'pizza'=>11,'tartiflette'=>11,
|
||||
'croque'=>11,'hachis parmentier'=>11,'paella'=>11,'moussaka'=>11,
|
||||
'tarte'=>11,'flamiche'=>11,'pissaladiere'=>11,
|
||||
// 12 = Sauces & Condiments
|
||||
'ketchup'=>12,'mayonnaise'=>12,'moutarde'=>12,'vinaigrette'=>12,
|
||||
'sauce soja'=>12,'tabasco'=>12,'huile'=>12,'vinaigre'=>12,
|
||||
'sauce tomate'=>12,'pesto'=>12,'sriracha'=>12,'worcester'=>12,
|
||||
'nuoc mam'=>12,'sauce'=>12,'condiment'=>12,'worcestershire'=>12,
|
||||
// 13 = Petit déjeuner
|
||||
'cereale'=>13,'muesli'=>13,'granola'=>13,'corn flakes'=>13,
|
||||
'miel'=>13,'confiture'=>13,'nutella'=>13,'beurre de cacahuete'=>13,
|
||||
'sirop d erable'=>13,'sirop erable'=>13,'chocolat en poudre'=>13,
|
||||
// 14 = Biscuits & Gâteaux
|
||||
'biscuit'=>14,'gateau'=>14,'cookie'=>14,'madeleine'=>14,
|
||||
'financier'=>14,'quatre quarts'=>14,'brownie'=>14,'sable'=>14,
|
||||
'speculoos'=>14,'oreo'=>14,'lu'=>14,'petit beurre'=>14,'galette'=>14,
|
||||
'palmier'=>14,'macaron'=>14,'eclair'=>14,
|
||||
// 15 = Confiserie
|
||||
'chocolat'=>15,'bonbon'=>15,'reglisse'=>15,'caramel'=>15,
|
||||
'nougat'=>15,'marshmallow'=>15,'guimauve'=>15,'sucette'=>15,
|
||||
'chewing gum'=>15,'pastille'=>15,'calisson'=>15,
|
||||
// 16 = Dessert
|
||||
'yaourt dessert'=>16,'creme dessert'=>16,'mousse au chocolat'=>16,
|
||||
'tiramisu'=>16,'creme brulee'=>16,'flan'=>16,'crepe'=>16,
|
||||
'madeleine'=>16,'profiterole'=>16,'eclair'=>16,
|
||||
// 17 = Beauté & Hygiène
|
||||
'profiterole'=>16,
|
||||
'shampoing'=>17,'gel douche'=>17,'savon'=>17,'dentifrice'=>17,
|
||||
'deodorant'=>17,'crème visage'=>17,'crème corps'=>17,'rasoir'=>17,
|
||||
'mousse a raser'=>17,'coton'=>17,'lingette'=>17,'maquillage'=>17,
|
||||
'parfum'=>17,'brosse a dents'=>17,'fil dentaire'=>17,'serum'=>17,
|
||||
'hydratant'=>17,'demaquillant'=>17,
|
||||
// 18 = Bébé
|
||||
'couche'=>18,'biberon'=>18,'lait infantile'=>18,'compote bebe'=>18,
|
||||
'pot bebe'=>18,'puree bebe'=>18,'lingette bebe'=>18,'savon bebe'=>18,
|
||||
'creme bebe'=>18,'sucette bebe'=>18,
|
||||
// 19 = Entretien
|
||||
'lessive'=>19,'liquide vaisselle'=>19,'nettoyant'=>19,'degraissant'=>19,
|
||||
'deboucheur'=>19,'anticalcaire'=>19,'eponge'=>19,'serpillere'=>19,
|
||||
'sac poubelle'=>19,'papier toilette'=>19,'sopalin'=>19,'essuie tout'=>19,
|
||||
'aluminium'=>19,'film plastique'=>19,'sac congelation'=>19,
|
||||
'nettoyant wc'=>19,'desinfectant'=>19,'vitre'=>19,'javel'=>19,
|
||||
// 20 = Animaux
|
||||
'croquette'=>20,'patee'=>20,'litiere'=>20,'os'=>20,
|
||||
'friandise animale'=>20,'nourriture chat'=>20,'nourriture chien'=>20,
|
||||
// 21 = Maison & Jardin
|
||||
'ampoule'=>21,'pile'=>21,'bougie'=>21,'allumette'=>21,
|
||||
'ruban adhesif'=>21,'colle'=>21,'vis'=>21,'terreau'=>21,
|
||||
'engrais'=>21,'pot de fleur'=>21,'arrosoir'=>21,'graine'=>21,
|
||||
'tournevis'=>21,'marteau'=>21,'clou'=>21,'cle'=>21,
|
||||
// 22 = Pharmacie
|
||||
'doliprane'=>22,'ibuprofene'=>22,'paracetamol'=>22,'aspirine'=>22,
|
||||
'bandage'=>22,'pansement'=>22,'thermometre'=>22,'serum physiologique'=>22,
|
||||
'vitamine'=>22,'complement'=>22,'masque'=>22,'gant medical'=>22,
|
||||
'sirop'=>22,'antihistaminique'=>22,'antidouleur'=>22,
|
||||
'antihistaminique'=>22,'antidouleur'=>22,
|
||||
];
|
||||
|
||||
// Check full phrase
|
||||
if (isset($map[$normalized])) return $map[$normalized];
|
||||
|
||||
// Check individual words
|
||||
$words = explode(' ', $normalized);
|
||||
foreach ($words as $w) {
|
||||
if (strlen($w) >= 3 && isset($map[$w])) return $map[$w];
|
||||
}
|
||||
|
||||
// Partial match (contains)
|
||||
foreach ($map as $kw => $catPos) {
|
||||
if (str_contains($normalized, $kw)) return $catPos;
|
||||
}
|
||||
@@ -237,12 +212,10 @@ function liste_builtin_detect(string $normalized): ?int {
|
||||
function liste_detect_category(PDO $pdo, string $label): ?int {
|
||||
$norm = liste_normalize_detect($label);
|
||||
|
||||
// 1. Learned rules (exact normalized label)
|
||||
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
|
||||
$r->execute([$norm]);
|
||||
if ($row = $r->fetch()) return (int)$row['category_id'];
|
||||
|
||||
// 2. Learned rules (individual words)
|
||||
foreach (explode(' ', $norm) as $w) {
|
||||
if (strlen($w) < 3) continue;
|
||||
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
|
||||
@@ -250,11 +223,9 @@ function liste_detect_category(PDO $pdo, string $label): ?int {
|
||||
if ($row = $r->fetch()) return (int)$row['category_id'];
|
||||
}
|
||||
|
||||
// 3. Built-in dictionary → map position to real category id
|
||||
$pos = liste_builtin_detect($norm);
|
||||
if ($pos === null) return null;
|
||||
|
||||
// Get category id by position order
|
||||
$r = $pdo->query("SELECT id FROM pf_list_categories ORDER BY position, id");
|
||||
$ids = $r->fetchAll(PDO::FETCH_COLUMN);
|
||||
return $ids[$pos - 1] ?? null;
|
||||
@@ -266,7 +237,6 @@ function liste_learn(PDO $pdo, string $label, int $category_id): void {
|
||||
$pdo->prepare("INSERT INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)
|
||||
ON DUPLICATE KEY UPDATE category_id=VALUES(category_id)")
|
||||
->execute([$norm, $category_id]);
|
||||
// Also learn individual meaningful words
|
||||
foreach (explode(' ', $norm) as $w) {
|
||||
if (strlen($w) >= 4) {
|
||||
$pdo->prepare("INSERT IGNORE INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)")
|
||||
@@ -301,7 +271,6 @@ if ($action === 'set_category' && $method === 'POST') {
|
||||
|
||||
$pdo->prepare("UPDATE pf_grocery_items SET category_id=? WHERE id=?")->execute([$category_id, $item_id]);
|
||||
|
||||
// Learn from manual assignment
|
||||
if ($category_id !== null) {
|
||||
$row = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?");
|
||||
$row->execute([$item_id]);
|
||||
@@ -431,11 +400,11 @@ 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;
|
||||
}
|
||||
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'unknown action: ' . $action]);
|
||||
echo json_encode(['error' => 'unknown action: ' . $action]);
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
// modules/meals/includes/api/import-pdf.php
|
||||
require dirname(__DIR__, 4) . '/includes/auth.php';
|
||||
require dirname(__DIR__, 4) . '/includes/db.php';
|
||||
require dirname(__DIR__, 4) . '/vendor/autoload.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 (via smalot/pdfparser pour un décodage
|
||||
// fiable des polices Type0/CID + tables ToUnicode)
|
||||
// ---------------------------------------------------------
|
||||
class NativePdfParser {
|
||||
// strtoupper() seul ne gère pas les accents (ex: "Déclinaison" -> "DCLINAISON"
|
||||
// au lieu de "DECLINAISON"), ce qui empêchait ce label d'être reconnu.
|
||||
private static function cleanLabel($text) {
|
||||
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
|
||||
return strtoupper(preg_replace('/[^A-Z]/i', '', $ascii ?: $text));
|
||||
}
|
||||
|
||||
public static function getMealData($filename) {
|
||||
$texts = [];
|
||||
|
||||
$parser = new \Smalot\PdfParser\Parser();
|
||||
$pdf = $parser->parseFile($filename);
|
||||
|
||||
foreach ($pdf->getPages() as $page) {
|
||||
foreach ($page->getDataTm() as $entry) {
|
||||
[$tm, $rawText] = $entry;
|
||||
$textVal = trim((string) $rawText);
|
||||
if ($textVal === '') continue;
|
||||
self::addText($texts, $textVal, (float) $tm[4], (float) $tm[5]);
|
||||
}
|
||||
}
|
||||
|
||||
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 = self::cleanLabel($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 = self::cleanLabel($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']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!in_array('PLATCHAUD', array_column($rowHeaders, 'label')) || !in_array('GARNITURE', array_column($rowHeaders, 'label'))) {
|
||||
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 = self::cleanLabel($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 : de quelle rangée (en-tête) ce texte est-il le plus proche ?
|
||||
// Le texte d'une rangée déborde souvent au-dessus de son propre label (lignes
|
||||
// empilées vers le haut) sur une hauteur variable d'un menu à l'autre : une
|
||||
// fenêtre à tolérance fixe est donc peu fiable. Le plus proche voisin (même
|
||||
// principe que pour les colonnes de jour ci-dessous) s'adapte automatiquement.
|
||||
$bestRow = null;
|
||||
$minRowDiff = PHP_FLOAT_MAX;
|
||||
foreach ($rowHeaders as $rh) {
|
||||
$diff = abs($t['y'] - $rh['y']);
|
||||
if ($diff < $minRowDiff) {
|
||||
$minRowDiff = $diff;
|
||||
$bestRow = $rh['label'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestRow === 'PLATCHAUD' || $bestRow === 'GARNITURE') {
|
||||
// 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' => $bestRow === 'PLATCHAUD' ? '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) {
|
||||
// smalot/pdfparser décode déjà correctement les polices Type0/CID + ToUnicode :
|
||||
// il ne reste plus qu'à nettoyer les caractères parasites résiduels.
|
||||
$m = str_replace("\0", "", $rawText);
|
||||
$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('/(?:du[\s-]+)?(\d{1,2})[\s-]+(?:([a-zèéû]+)[\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'];
|
||||
|
||||
$startMonthRaw = !empty($m[2]) ? strtolower($m[2]) : strtolower($m[3]);
|
||||
$endMonthRaw = strtolower($m[3]);
|
||||
|
||||
$startMonthNum = $monthsMap[$startMonthRaw] ?? '09';
|
||||
$endMonthNum = $monthsMap[$endMonthRaw] ?? '09';
|
||||
|
||||
$startYear = (int)$m[4];
|
||||
// Règle métier : Bascule d'année (ex: 28 décembre au 3 janvier 2026 -> 2025-12-28)
|
||||
if ($startMonthNum === '12' && $endMonthNum === '01') {
|
||||
$startYear--;
|
||||
}
|
||||
|
||||
$mondayDate = $startYear . '-' . $startMonthNum . '-' . str_pad($m[1], 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
if (!$mondayDate) {
|
||||
echo json_encode(['success' => false, 'error' => tr('meal_err_filename')]);
|
||||
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()]);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,512 @@
|
||||
<?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()">×</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');">×</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" id="mealDropzone" onclick="document.getElementById('canteenPdf').click()">
|
||||
<span class="meal-dropzone-icon" id="mealDropzoneIcon">📥</span>
|
||||
<span id="pdfFileName"><?= tr('meal_drop_pdf') ?></span>
|
||||
<input type="file" id="canteenPdf" name="pdf_file" accept=".pdf" style="display: none;" required>
|
||||
</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', () => {
|
||||
// 1. 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2. NOUVEAU : Initialisation du Drag & Drop pour l'import PDF
|
||||
const dropzone = document.getElementById('mealDropzone');
|
||||
const fileInput = document.getElementById('canteenPdf');
|
||||
|
||||
if (dropzone && fileInput) {
|
||||
// Empêcher l'ouverture automatique du fichier par le navigateur
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropzone.addEventListener(eventName, e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
// Feedback visuel au survol
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropzone.addEventListener(eventName, () => dropzone.classList.add('drag-over'), false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropzone.addEventListener(eventName, () => dropzone.classList.remove('drag-over'), false);
|
||||
});
|
||||
|
||||
// Récupération du fichier au lâcher (Drop)
|
||||
dropzone.addEventListener('drop', e => {
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
updateDropzoneState(fileInput.files[0]);
|
||||
}
|
||||
}, false);
|
||||
|
||||
// Récupération au clic classique via l'input
|
||||
fileInput.addEventListener('change', function() {
|
||||
updateDropzoneState(this.files[0]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// NOUVEAU : Fonction utilitaire pour gérer l'état visuel de la Dropzone
|
||||
window.updateDropzoneState = function(file) {
|
||||
const dropzone = document.getElementById('mealDropzone');
|
||||
const fileNameDisplay = document.getElementById('pdfFileName');
|
||||
const dropzoneIcon = document.getElementById('mealDropzoneIcon');
|
||||
|
||||
if (file) {
|
||||
fileNameDisplay.innerText = file.name;
|
||||
if (dropzone) dropzone.classList.add('has-file');
|
||||
if (dropzoneIcon) dropzoneIcon.innerText = '✅';
|
||||
} else {
|
||||
fileNameDisplay.innerText = window.I18N['meal_drop_pdf'] || 'Cliquer pour ajouter le PDF';
|
||||
if (dropzone) dropzone.classList.remove('has-file');
|
||||
if (dropzoneIcon) dropzoneIcon.innerText = '📥';
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Focus Context Bar & Gestion dynamique du clavier (Mobile)
|
||||
// ============================================================================
|
||||
let copyState = { type: null, sourceDate: null, service: null, personIdx: null, sourceElements: [], fullDayName: '' };
|
||||
|
||||
// Fonction de calcul pour maintenir la barre au-dessus du clavier virtuel
|
||||
const updateToolbarPosition = () => {
|
||||
const toolbar = document.getElementById('contextToolbar');
|
||||
if (toolbar && toolbar.classList.contains('active') && window.visualViewport) {
|
||||
const layoutHeight = document.documentElement.clientHeight;
|
||||
const visualHeight = window.visualViewport.height;
|
||||
const offsetTop = window.visualViewport.offsetTop;
|
||||
|
||||
// Calcul exact de l'espace masqué par le clavier
|
||||
const bottomOffset = layoutHeight - (visualHeight + offsetTop);
|
||||
toolbar.style.bottom = Math.max(0, bottomOffset) + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
// Écouteurs pour s'adapter au redimensionnement (ouverture clavier) et au scroll
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', updateToolbarPosition);
|
||||
window.visualViewport.addEventListener('scroll', updateToolbarPosition);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// On déclenche le repositionnement avec un léger délai pour laisser le clavier monter
|
||||
setTimeout(updateToolbarPosition, 150);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('focusout', (e) => {
|
||||
setTimeout(() => {
|
||||
if(document.activeElement && !document.activeElement.classList.contains('js-meal-input')) {
|
||||
const toolbar = document.getElementById('contextToolbar');
|
||||
if (toolbar) {
|
||||
toolbar.classList.remove('active');
|
||||
toolbar.style.bottom = ''; // On retire le style JS pour redonner le contrôle au CSS (-80px)
|
||||
}
|
||||
}
|
||||
}, 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/food/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);
|
||||
}
|
||||
|
||||
// Fonction Javascript d'Import PDF mise à jour
|
||||
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();
|
||||
updateDropzoneState(null);
|
||||
}
|
||||
};
|
||||
|
||||
// NOUVEAU : Interception du collage depuis Excel / Google Sheets
|
||||
document.addEventListener('paste', async function(e) {
|
||||
// On ne s'active que si on colle dans un textarea de repas
|
||||
if (!e.target.classList.contains('js-meal-input')) return;
|
||||
|
||||
const clipboardData = e.clipboardData || window.clipboardData;
|
||||
const pastedText = clipboardData.getData('text/plain');
|
||||
|
||||
// Si le texte ne contient ni tabulation ni saut de ligne, c'est un collage simple.
|
||||
// On laisse le navigateur faire son comportement par défaut.
|
||||
if (!pastedText.includes('\t') && !pastedText.includes('\n')) return;
|
||||
|
||||
// C'est une grille ! On bloque le comportement par défaut
|
||||
e.preventDefault();
|
||||
|
||||
const rows = pastedText.split(/\r?\n/);
|
||||
const startTd = e.target.closest('td');
|
||||
const startTr = e.target.closest('tr');
|
||||
const startColIndex = Array.from(startTr.children).indexOf(startTd);
|
||||
|
||||
let currentTr = startTr;
|
||||
let updates = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (!currentTr) break; // Fin du tableau visuel atteinte
|
||||
|
||||
// Ignorer la dernière ligne vide souvent générée par les tableurs lors d'une copie
|
||||
if (rows[i].trim() === '' && i === rows.length - 1) continue;
|
||||
|
||||
const cells = rows[i].split('\t');
|
||||
const trChildren = Array.from(currentTr.children);
|
||||
|
||||
for (let j = 0; j < cells.length; j++) {
|
||||
const targetTd = trChildren[startColIndex + j];
|
||||
if (!targetTd) continue; // Fin de la colonne visuelle atteinte
|
||||
|
||||
const targetTextarea = targetTd.querySelector('.js-meal-input');
|
||||
if (targetTextarea) {
|
||||
const val = cells[j].trim();
|
||||
targetTextarea.value = val;
|
||||
|
||||
// Animation de succès visuel
|
||||
if (typeof flashSuccess === 'function') flashSuccess(targetTextarea);
|
||||
|
||||
// Préparation pour le Bulk Save
|
||||
updates.push({
|
||||
plan_date: targetTextarea.dataset.d,
|
||||
service: targetTextarea.dataset.s,
|
||||
person_id: targetTextarea.dataset.p,
|
||||
meal_name: val
|
||||
});
|
||||
}
|
||||
}
|
||||
// Descendre d'une ligne dans le tableau (ex: passer de Midi à Soir, ou de Soir au Midi du lendemain)
|
||||
currentTr = currentTr.nextElementSibling;
|
||||
}
|
||||
|
||||
// Sauvegarde groupée optimisée
|
||||
if (updates.length > 0) {
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'save_bulk');
|
||||
fd.append('updates', JSON.stringify(updates));
|
||||
|
||||
try {
|
||||
await pachaFetch('/modules/food/includes/api/save-meals.php', { method: 'POST', body: fd });
|
||||
if (window.showToast) showToast(window.I18N['meal_paste_success'] || 'Grille collée avec succès !', 'success');
|
||||
} catch(err) {
|
||||
console.error("Erreur de sauvegarde lors du collage", err);
|
||||
if (window.showToast) showToast(window.I18N['error_occured'], 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
+1003
-1089
File diff suppressed because it is too large.
Load diff
+1270
-335
File diff suppressed because it is too large.
Load diff
@@ -7,11 +7,20 @@ require_login();
|
||||
// ---------------------------------------------------------------------------
|
||||
// INTERCEPTIONS AJAX (Exécution ultra rapide sans rechargement lourd)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration', 'add_single_item'])) {
|
||||
if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration', 'add_single_item', 'update_holiday_note'])) {
|
||||
header('Content-Type: application/json');
|
||||
session_write_close(); // 🚀 LIBÈRE LA SESSION : Permet d'autres requêtes simultanées sans bloquer le navigateur
|
||||
session_write_close();
|
||||
|
||||
try {
|
||||
if ($_POST['action'] === 'update_holiday_note') {
|
||||
$hId = (int)$_POST['holiday_id'];
|
||||
$notes = $_POST['notes'] ?? '';
|
||||
$stmt = $pdo->prepare("UPDATE pf_holidays SET notes = ? WHERE id = ?");
|
||||
$stmt->execute([$notes, $hId]);
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_POST['action'] === 'add_single_item') {
|
||||
$holiday_id = (int)$_POST['holiday_id'];
|
||||
$sort_order = (int)$_POST['sort_order'];
|
||||
@@ -25,16 +34,43 @@ if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime
|
||||
$date = !empty($_POST['item_date']) ? $_POST['item_date'] : null;
|
||||
$time = !empty($_POST['item_time']) ? $_POST['item_time'] : null;
|
||||
|
||||
// Récupération sécurisée du contexte
|
||||
$context = !empty($_POST['expense_context']) ? $_POST['expense_context'] : (!empty($_POST['context']) ? $_POST['context'] : 'local');
|
||||
$amount = (float)$_POST['amount'];
|
||||
$name = $_POST['name'] ?? 'Trajet';
|
||||
$category = $_POST['category'] ?? 'transport';
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// 🛡️ ANTI-DOUBLON ABSOLU
|
||||
if ($context === 'transit') {
|
||||
$stmtCheck = $pdo->prepare("SELECT id FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ? AND expense_context = 'transit'");
|
||||
$stmtCheck->execute([$holiday_id, $sort_order]);
|
||||
$existingId = $stmtCheck->fetchColumn();
|
||||
|
||||
if ($existingId) {
|
||||
// Mise à jour de la ligne existante
|
||||
$stmtUp = $pdo->prepare("UPDATE pf_holidays_items SET item_date = ?, item_time = ?, amount = ?, duration = ?, name = ? WHERE id = ?");
|
||||
$stmtUp->execute([$date, $time, $amount, $dur, $name, $existingId]);
|
||||
$pdo->commit();
|
||||
echo json_encode(['success' => true, 'id' => $existingId]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Insertion si aucun doublon n'existe
|
||||
$ins = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng, sort_order, step_start_date, step_end_date, step_type, expense_context, duration, item_date, item_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
$ins->execute([
|
||||
$holiday_id, $_POST['category'], $_POST['name'], (float)$_POST['amount'], 0,
|
||||
$holiday_id, $category, $name, $amount, 0,
|
||||
$stepInfo['location_name'], $stepInfo['lat'], $stepInfo['lng'],
|
||||
$sort_order, $stepInfo['step_start_date'], $stepInfo['step_end_date'],
|
||||
$stepInfo['step_type'], $_POST['context'], $dur, $date, $time
|
||||
$stepInfo['step_type'], $context, $dur, $date, $time
|
||||
]);
|
||||
|
||||
$newId = $pdo->lastInsertId();
|
||||
$pdo->commit();
|
||||
echo json_encode(['success' => true]);
|
||||
|
||||
echo json_encode(['success' => true, 'id' => $newId]);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['success' => false, 'error' => 'Etape introuvable']);
|
||||
@@ -61,6 +97,7 @@ if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
if ($pdo->inTransaction()) { $pdo->rollBack(); }
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
@@ -92,10 +129,19 @@ if ($holiday_id > 0 && !empty($location_name)) {
|
||||
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]);
|
||||
$target_order = $old_sort_order;
|
||||
} else {
|
||||
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
|
||||
$stmtMax->execute([$holiday_id]);
|
||||
$max = $stmtMax->fetchColumn();
|
||||
$target_order = ($max !== null) ? (int)$max + 1 : 0;
|
||||
// NOUVEAU : Logique d'intercalage d'étape
|
||||
$insert_after = $_POST['insert_after'] ?? 'end';
|
||||
|
||||
if ($insert_after === 'end') {
|
||||
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
|
||||
$stmtMax->execute([$holiday_id]);
|
||||
$max = $stmtMax->fetchColumn();
|
||||
$target_order = ($max !== null) ? (int)$max + 1 : 0;
|
||||
} else {
|
||||
$target_order = (int)$insert_after + 1;
|
||||
// On décale toutes les étapes suivantes vers le bas
|
||||
$pdo->prepare("UPDATE pf_holidays_items SET sort_order = sort_order + 1 WHERE holiday_id = ? AND sort_order >= ?")->execute([$holiday_id, $target_order]);
|
||||
}
|
||||
}
|
||||
|
||||
$step_start = !empty($_POST['step_start_date']) ? $_POST['step_start_date'] : null;
|
||||
@@ -124,7 +170,11 @@ if ($holiday_id > 0 && !empty($location_name)) {
|
||||
$dur = !empty($_POST['items']['duration'][$i]) ? (int)$_POST['items']['duration'][$i] : 1;
|
||||
$context = !empty($_POST['items']['context'][$i]) ? $_POST['items']['context'][$i] : 'local';
|
||||
|
||||
$stmt->execute([$holiday_id, $cat, $name, $amount, $paid, $location_name, $lat, $lng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $step_type, $context]);
|
||||
// Récupération des coordonnées spécifiques à l'activité (ou fallback sur l'étape)
|
||||
$itemLat = !empty($_POST['items']['lat'][$i]) ? (float)$_POST['items']['lat'][$i] : $lat;
|
||||
$itemLng = !empty($_POST['items']['lng'][$i]) ? (float)$_POST['items']['lng'][$i] : $lng;
|
||||
|
||||
$stmt->execute([$holiday_id, $cat, $name, $amount, $paid, $location_name, $itemLat, $itemLng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $step_type, $context]);
|
||||
$validItemsCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+561
-357
File diff suppressed because it is too large.
Load diff
@@ -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']) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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; }
|
||||
@@ -153,7 +153,7 @@ function renderAttachmentsView(atts,noteId){
|
||||
<div class="memo-att-title">🖼️ Images</div>
|
||||
<div class="att-img-grid">${images.map(a=>`
|
||||
<div class="att-img-item" onclick="openLightbox('${API}?action=file&id=${a.id}')">
|
||||
<img src="${API}?action=file&id=${a.id}" loading="lazy" alt="${escHtml(a.original_name||'')}">
|
||||
<img src="${API}?action=file&id=${a.id}" alt="${escHtml(a.original_name||'')}">
|
||||
<button class="att-del" onclick="event.stopPropagation();deleteAttachment(${a.id},${noteId})" title="Supprimer">✕</button>
|
||||
</div>`).join('')}</div></div>`;
|
||||
}
|
||||
|
||||
+6
-16
@@ -26,33 +26,23 @@ $stops = $params['stops'];
|
||||
$fuelL100 = (float)($params['fuel_l100'] ?? 7);
|
||||
$fuelPrice= (float)($params['fuel_price'] ?? 1.85);
|
||||
|
||||
// --- Load databases ---
|
||||
$tollsPath = __DIR__ . '/data/tolls.json';
|
||||
$gpsPath = __DIR__ . '/data/toll_gps.json';
|
||||
// --- Load pre-indexed PHP database ---
|
||||
$tollsDataPath = __DIR__ . '/data/tolls.data.php';
|
||||
$gpsPath = __DIR__ . '/data/toll_gps.json';
|
||||
|
||||
if (!file_exists($tollsPath)) {
|
||||
if (!file_exists($tollsDataPath)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Base de péages introuvable']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$tollsRaw = json_decode(file_get_contents($tollsPath), true);
|
||||
$tollData = $tollsRaw['data'] ?? [];
|
||||
// Grâce à OPcache, le fichier est exécuté et stocké en RAM partagée
|
||||
$tollIndex = require $tollsDataPath;
|
||||
|
||||
$gpsData = [];
|
||||
if (file_exists($gpsPath)) {
|
||||
$gpsData = json_decode(file_get_contents($gpsPath), true) ?? [];
|
||||
}
|
||||
|
||||
// --- Build lookup index: [op][entry][exit] => c1_price ---
|
||||
$tollIndex = [];
|
||||
foreach ($tollData as $row) {
|
||||
$op = $row['op'];
|
||||
$e = $row['e'];
|
||||
$x = $row['x'];
|
||||
$c1 = (float)$row['c1'];
|
||||
$tollIndex[$op][$e][$x] = $c1;
|
||||
}
|
||||
|
||||
// --- Helper: great-circle distance (km) ---
|
||||
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
|
||||
$R = 6371.0;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1 +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
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
// offline.php
|
||||
require_once __DIR__ . '/includes/i18n.php';
|
||||
|
||||
$pageTitle = tr('offline_title');
|
||||
$activePage = "offline";
|
||||
require __DIR__ . '/header.php';
|
||||
?>
|
||||
|
||||
<div class="pf-container" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; text-align: center; padding: 20px;">
|
||||
<div style="font-size: 5rem; margin-bottom: 20px; filter: grayscale(1); opacity: 0.7;">📡</div>
|
||||
<h1 style="color: var(--text-main); margin-bottom: 10px;"><?= tr('offline_heading') ?></h1>
|
||||
<p style="color: var(--text-muted); font-size: 1.1rem; max-width: 400px; margin-bottom: 30px;">
|
||||
<?= tr('offline_message') ?>
|
||||
</p>
|
||||
<button onclick="window.location.reload();" class="pf-btn">
|
||||
🔄 <?= tr('offline_retry_btn') ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/footer.php'; ?>
|
||||
+767
@@ -0,0 +1,767 @@
|
||||
<!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>Preview Mobile - Import CSV</title>
|
||||
<style>
|
||||
/* === Variables HouseHub (Simulées) === */
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--primary-dark: #1d4ed8;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--bg-page: #f8fafc;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-subtle: #f1f5f9;
|
||||
--text-main: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--border-light: #e2e8f0;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: #333;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Conteneur simulant un écran mobile */
|
||||
.mobile-simulator {
|
||||
width: 100%;
|
||||
max-width: 414px; /* Taille iPhone Max */
|
||||
height: 100vh;
|
||||
max-height: 896px;
|
||||
background: var(--bg-page);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* En-tête de la modale */
|
||||
.preview-header {
|
||||
background: var(--bg-panel);
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.preview-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.preview-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Système d'onglets pour la preview */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: var(--bg-subtle);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab-btn.active {
|
||||
background: var(--bg-panel);
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Conteneur principal scrollable */
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
padding-bottom: 80px; /* Espace pour le bouton valider */
|
||||
}
|
||||
|
||||
.view-section {
|
||||
display: none;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
.view-section.active {
|
||||
display: block;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Composants de formulaire communs */
|
||||
.pf-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.pf-select,
|
||||
.pf-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-main);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pf-select:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.amount-badge {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.amount-expense {
|
||||
color: var(--text-main);
|
||||
}
|
||||
.amount-income {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
PROPOSITION 1 : CARTES VERTICALES
|
||||
========================================= */
|
||||
.prop1-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.prop1-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.prop1-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prop1-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin: 4px 0 0 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.prop1-ignore-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.prop1-ignore-row input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--danger);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
PROPOSITION 2 : ACCORDÉON (Liste compacte)
|
||||
========================================= */
|
||||
.prop2-container {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.prop2-item {
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.prop2-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.prop2-summary {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-panel);
|
||||
cursor: pointer;
|
||||
}
|
||||
.prop2-summary-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.prop2-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.prop2-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.prop2-status {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.prop2-status.ok {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.prop2-details {
|
||||
display: none;
|
||||
padding: 16px;
|
||||
background: var(--bg-subtle);
|
||||
border-top: 1px dashed var(--border-light);
|
||||
}
|
||||
.prop2-item.open .prop2-details {
|
||||
display: block;
|
||||
}
|
||||
.prop2-item.open .prop2-summary {
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
PROPOSITION 3 : BOTTOM SHEET (Native feel)
|
||||
========================================= */
|
||||
.prop3-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.prop3-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.prop3-item:active {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.prop3-item.categorized {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
.prop3-item.pending {
|
||||
border-left: 4px solid var(--warning);
|
||||
}
|
||||
|
||||
.bottom-sheet-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.bottom-sheet {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-panel);
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 24px;
|
||||
z-index: 101;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: 0 -10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.bottom-sheet.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.bottom-sheet-overlay.open {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
.bs-handle {
|
||||
width: 40px;
|
||||
height: 5px;
|
||||
background: var(--border-strong);
|
||||
border-radius: 5px;
|
||||
margin: -10px auto 20px auto;
|
||||
}
|
||||
|
||||
/* Footer fixe */
|
||||
.preview-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid var(--border-light);
|
||||
z-index: 50;
|
||||
}
|
||||
.pf-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="mobile-simulator">
|
||||
<div class="preview-header">
|
||||
<h2 class="preview-title">
|
||||
<span>📥 Importation CSV</span>
|
||||
<button class="preview-close">×</button>
|
||||
</h2>
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" onclick="switchView('prop1')">
|
||||
1. Cartes
|
||||
</button>
|
||||
<button class="tab-btn" onclick="switchView('prop2')">
|
||||
2. Accordéon
|
||||
</button>
|
||||
<button class="tab-btn" onclick="switchView('prop3')">
|
||||
3. Bottom Sheet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-body">
|
||||
<!-- ======================================================= -->
|
||||
<!-- PROPOSITION 1 : CARTES VERTICALES -->
|
||||
<!-- ======================================================= -->
|
||||
<div id="prop1" class="view-section active">
|
||||
<p
|
||||
style="font-size: 0.85rem; color: var(--text-muted); margin-top: 0"
|
||||
>
|
||||
Vision aérée. Parfait s'il y a peu de lignes à importer.
|
||||
</p>
|
||||
|
||||
<!-- Ligne Dépense -->
|
||||
<div class="prop1-card">
|
||||
<div class="prop1-header">
|
||||
<div>
|
||||
<div class="prop1-date">12/10/2026</div>
|
||||
<h4 class="prop1-label">AUCHAN SUPERMARCHE</h4>
|
||||
</div>
|
||||
<div class="amount-badge amount-expense">- 45,20 €</div>
|
||||
</div>
|
||||
|
||||
<label class="pf-label">Catégorie</label>
|
||||
<select
|
||||
class="pf-select"
|
||||
onchange="toggleLinkedItem(this, 'prop1-linked-1')"
|
||||
>
|
||||
<option value="">-- À définir --</option>
|
||||
<option value="FMCG">🛒 Courses (FMCG)</option>
|
||||
<option value="FIXED">🏢 Charges Fixes</option>
|
||||
<option value="INCOME">💵 Revenus</option>
|
||||
</select>
|
||||
|
||||
<div id="prop1-linked-1" style="display: none">
|
||||
<label class="pf-label">Lier à une charge fixe</label>
|
||||
<select class="pf-select">
|
||||
<option value="">-- Sélectionner --</option>
|
||||
<option value="1">Assurance Auto</option>
|
||||
<option value="2">Loyer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="prop1-ignore-row pf-label"
|
||||
style="margin: 0; font-weight: 500; cursor: pointer"
|
||||
>
|
||||
<input type="checkbox" /> Ignorer cette ligne
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Ligne Abonnement -->
|
||||
<div class="prop1-card">
|
||||
<div class="prop1-header">
|
||||
<div>
|
||||
<div class="prop1-date">14/10/2026</div>
|
||||
<h4 class="prop1-label">NETFLIX.COM</h4>
|
||||
</div>
|
||||
<div class="amount-badge amount-expense">- 13,49 €</div>
|
||||
</div>
|
||||
|
||||
<label class="pf-label">Catégorie</label>
|
||||
<select
|
||||
class="pf-select"
|
||||
onchange="toggleLinkedItem(this, 'prop1-linked-2')"
|
||||
>
|
||||
<option value="FIXED" selected>🏢 Charges Fixes</option>
|
||||
<option value="FMCG">🛒 Courses (FMCG)</option>
|
||||
</select>
|
||||
|
||||
<div id="prop1-linked-2" style="display: block">
|
||||
<label class="pf-label">Lier à une charge fixe</label>
|
||||
<select class="pf-select">
|
||||
<option value="3" selected>Abonnement Netflix</option>
|
||||
<option value="1">Assurance Auto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ligne Revenu -->
|
||||
<div class="prop1-card">
|
||||
<div class="prop1-header">
|
||||
<div>
|
||||
<div class="prop1-date">15/10/2026</div>
|
||||
<h4 class="prop1-label">VIR SEPA M. PERCO</h4>
|
||||
</div>
|
||||
<div class="amount-badge amount-income">+ 1 500,00 €</div>
|
||||
</div>
|
||||
|
||||
<label class="pf-label">Catégorie</label>
|
||||
<select class="pf-select">
|
||||
<option value="INCOME" selected>💵 Revenus</option>
|
||||
<option value="FMCG">🛒 Courses (FMCG)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================================================= -->
|
||||
<!-- PROPOSITION 2 : ACCORDÉON -->
|
||||
<!-- ======================================================= -->
|
||||
<div id="prop2" class="view-section">
|
||||
<p
|
||||
style="font-size: 0.85rem; color: var(--text-muted); margin-top: 0"
|
||||
>
|
||||
Vision compacte. Idéal pour un gros volume d'imports.
|
||||
</p>
|
||||
|
||||
<div class="prop2-container">
|
||||
<!-- Item 1 -->
|
||||
<div class="prop2-item" onclick="toggleAccordion(this)">
|
||||
<div class="prop2-summary">
|
||||
<div class="prop2-summary-left">
|
||||
<div class="prop2-date">12/10/2026</div>
|
||||
<div class="prop2-label">AUCHAN SUPERMARCHE</div>
|
||||
</div>
|
||||
<div class="amount-badge amount-expense">- 45,20 €</div>
|
||||
<div class="prop2-status" title="À définir"></div>
|
||||
</div>
|
||||
<div class="prop2-details" onclick="event.stopPropagation()">
|
||||
<label class="pf-label">Catégorie</label>
|
||||
<select
|
||||
class="pf-select"
|
||||
onchange="
|
||||
toggleLinkedItem(this, 'prop2-linked-1');
|
||||
updateAccordionStatus(this);
|
||||
"
|
||||
>
|
||||
<option value="">-- À définir --</option>
|
||||
<option value="FMCG">🛒 Courses (FMCG)</option>
|
||||
<option value="FIXED">🏢 Charges Fixes</option>
|
||||
</select>
|
||||
|
||||
<div id="prop2-linked-1" style="display: none">
|
||||
<label class="pf-label">Lier à une charge fixe</label>
|
||||
<select class="pf-select">
|
||||
<option value="">-- Sélectionner --</option>
|
||||
<option value="1">Assurance Auto</option>
|
||||
</select>
|
||||
</div>
|
||||
<label
|
||||
class="prop1-ignore-row pf-label"
|
||||
style="margin: 0; font-weight: 500"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange="updateAccordionStatus(this, true)"
|
||||
/>
|
||||
Ignorer
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item 2 -->
|
||||
<div class="prop2-item">
|
||||
<div
|
||||
class="prop2-summary"
|
||||
onclick="toggleAccordion(this.parentElement)"
|
||||
>
|
||||
<div class="prop2-summary-left">
|
||||
<div class="prop2-date">14/10/2026</div>
|
||||
<div class="prop2-label">NETFLIX.COM</div>
|
||||
</div>
|
||||
<div class="amount-badge amount-expense">- 13,49 €</div>
|
||||
<div class="prop2-status ok" title="Validé"></div>
|
||||
</div>
|
||||
<div class="prop2-details" onclick="event.stopPropagation()">
|
||||
<label class="pf-label">Catégorie</label>
|
||||
<select class="pf-select">
|
||||
<option value="FIXED" selected>🏢 Charges Fixes</option>
|
||||
</select>
|
||||
<label class="pf-label">Lien</label>
|
||||
<select class="pf-select">
|
||||
<option value="3" selected>Abonnement Netflix</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================================================= -->
|
||||
<!-- PROPOSITION 3 : LISTE + BOTTOM SHEET -->
|
||||
<!-- ======================================================= -->
|
||||
<div id="prop3" class="view-section">
|
||||
<p
|
||||
style="font-size: 0.85rem; color: var(--text-muted); margin-top: 0"
|
||||
>
|
||||
Expérience 100% Mobile Native. Garde la liste toujours visible.
|
||||
</p>
|
||||
|
||||
<div class="prop3-list">
|
||||
<div
|
||||
class="prop3-item pending"
|
||||
onclick="
|
||||
openBottomSheet('AUCHAN SUPERMARCHE', '-45.20', 'expense', '')
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<div class="prop1-date">12/10/2026</div>
|
||||
<div class="prop1-label" style="font-size: 0.85rem">
|
||||
AUCHAN SUPERMARCHE
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
font-size: 0.7rem;
|
||||
color: var(--danger);
|
||||
margin-top: 2px;
|
||||
"
|
||||
>
|
||||
À définir
|
||||
</div>
|
||||
</div>
|
||||
<div class="amount-badge amount-expense">- 45,20 €</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="prop3-item categorized"
|
||||
onclick="
|
||||
openBottomSheet('NETFLIX.COM', '-13.49', 'expense', 'FIXED')
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<div class="prop1-date">14/10/2026</div>
|
||||
<div class="prop1-label" style="font-size: 0.85rem">
|
||||
NETFLIX.COM
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
font-size: 0.7rem;
|
||||
color: var(--success);
|
||||
margin-top: 2px;
|
||||
"
|
||||
>
|
||||
Charges Fixes
|
||||
</div>
|
||||
</div>
|
||||
<div class="amount-badge amount-expense">- 13,49 €</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Fixe -->
|
||||
<div class="preview-footer">
|
||||
<button class="pf-btn">Valider l'importation</button>
|
||||
</div>
|
||||
|
||||
<!-- Modale Bottom Sheet (Pour Prop 3) -->
|
||||
<div
|
||||
class="bottom-sheet-overlay"
|
||||
id="bs-overlay"
|
||||
onclick="closeBottomSheet()"
|
||||
></div>
|
||||
<div class="bottom-sheet" id="bs-modal">
|
||||
<div class="bs-handle"></div>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
id="bs-title"
|
||||
style="margin: 0; font-size: 1.1rem; color: var(--text-main)"
|
||||
>
|
||||
Titre
|
||||
</h3>
|
||||
<div
|
||||
id="bs-date"
|
||||
style="font-size: 0.8rem; color: var(--text-muted)"
|
||||
>
|
||||
12/10/2026
|
||||
</div>
|
||||
</div>
|
||||
<div id="bs-amount" class="amount-badge">0,00 €</div>
|
||||
</div>
|
||||
|
||||
<label class="pf-label">Catégorie</label>
|
||||
<select
|
||||
class="pf-select"
|
||||
id="bs-select-cat"
|
||||
onchange="toggleLinkedItem(this, 'bs-linked-item')"
|
||||
>
|
||||
<option value="">-- À définir --</option>
|
||||
<option value="FMCG">🛒 Courses (FMCG)</option>
|
||||
<option value="FIXED">🏢 Charges Fixes</option>
|
||||
<option value="INCOME">💵 Revenus</option>
|
||||
</select>
|
||||
|
||||
<div id="bs-linked-item" style="display: none">
|
||||
<label class="pf-label">Lier à une charge / revenu</label>
|
||||
<select class="pf-select">
|
||||
<option value="">-- Sélectionner --</option>
|
||||
<option value="1">Loyer</option>
|
||||
<option value="2">Assurance</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; margin-top: 20px">
|
||||
<button
|
||||
class="pf-btn"
|
||||
style="background: var(--bg-subtle); color: var(--danger); flex: 1"
|
||||
onclick="closeBottomSheet()"
|
||||
>
|
||||
Ignorer
|
||||
</button>
|
||||
<button class="pf-btn" style="flex: 2" onclick="closeBottomSheet()">
|
||||
Appliquer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Navigation entre les propositions
|
||||
function switchView(id) {
|
||||
document
|
||||
.querySelectorAll(".view-section")
|
||||
.forEach((el) => el.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll(".tab-btn")
|
||||
.forEach((el) => el.classList.remove("active"));
|
||||
|
||||
document.getElementById(id).classList.add("active");
|
||||
event.target.classList.add("active");
|
||||
}
|
||||
|
||||
// Affichage conditionnel des selects liés (Charges fixes / Revenus)
|
||||
function toggleLinkedItem(selectEl, targetId) {
|
||||
const target = document.getElementById(targetId);
|
||||
if (selectEl.value === "FIXED" || selectEl.value === "INCOME") {
|
||||
target.style.display = "block";
|
||||
} else {
|
||||
target.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Logique Accordéon (Prop 2)
|
||||
function toggleAccordion(itemEl) {
|
||||
const isOpen = itemEl.classList.contains("open");
|
||||
document
|
||||
.querySelectorAll(".prop2-item")
|
||||
.forEach((el) => el.classList.remove("open"));
|
||||
if (!isOpen) {
|
||||
itemEl.classList.add("open");
|
||||
}
|
||||
}
|
||||
|
||||
function updateAccordionStatus(element, isIgnore = false) {
|
||||
const itemEl = element.closest(".prop2-item");
|
||||
const statusDot = itemEl.querySelector(".prop2-status");
|
||||
|
||||
if (isIgnore && element.checked) {
|
||||
statusDot.className = "prop2-status ok";
|
||||
statusDot.style.background = "var(--text-muted)";
|
||||
} else if (itemEl.querySelector("select").value !== "") {
|
||||
statusDot.className = "prop2-status ok";
|
||||
statusDot.style.background = "var(--success)";
|
||||
} else {
|
||||
statusDot.className = "prop2-status";
|
||||
statusDot.style.background = "var(--danger)";
|
||||
}
|
||||
}
|
||||
|
||||
// Logique Bottom Sheet (Prop 3)
|
||||
function openBottomSheet(title, amount, type, currentCat) {
|
||||
document.getElementById("bs-title").innerText = title;
|
||||
document.getElementById("bs-amount").innerText = amount + " €";
|
||||
document.getElementById("bs-amount").className =
|
||||
"amount-badge " +
|
||||
(type === "expense" ? "amount-expense" : "amount-income");
|
||||
|
||||
const select = document.getElementById("bs-select-cat");
|
||||
select.value = currentCat;
|
||||
toggleLinkedItem(select, "bs-linked-item");
|
||||
|
||||
document.getElementById("bs-overlay").classList.add("open");
|
||||
document.getElementById("bs-modal").classList.add("open");
|
||||
}
|
||||
|
||||
function closeBottomSheet() {
|
||||
document.getElementById("bs-overlay").classList.remove("open");
|
||||
document.getElementById("bs-modal").classList.remove("open");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+310
-132
@@ -33,23 +33,28 @@ 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', 'liste', 'calendar_ios', 'printvault', 'planka', 'food'];
|
||||
$allViews = ['food_meals', 'food_liste', 'budget_prev', 'budget_epargne', 'budget_provisions'];
|
||||
|
||||
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
|
||||
$enabledViews = array_values(array_filter($allViews, fn($v) => isset($_POST['view_' . $v])));
|
||||
|
||||
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]);
|
||||
$meta_pdo->prepare("UPDATE families SET enabled_modules = ?, enabled_views = ? WHERE id = ?")
|
||||
->execute([json_encode($enabled), json_encode($enabledViews), $family_id]);
|
||||
$_SESSION['enabled_modules'] = $enabled;
|
||||
$success = "Modules mis à jour avec succès.";
|
||||
$_SESSION['enabled_views'] = $enabledViews;
|
||||
$success = tr('set_success_modules');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +64,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 +73,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 +85,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 +99,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 +116,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 +124,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 +134,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 +149,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 +176,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 +186,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 +202,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 +214,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 +247,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 +340,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 +360,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,19 +397,38 @@ 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'];
|
||||
$enabledViews = $_SESSION['enabled_views'] ?? ['food_meals', 'food_liste', 'budget_prev', 'budget_epargne', 'budget_provisions'];
|
||||
|
||||
$allModules = [
|
||||
'calendar' => ['icon' => '📅', 'label' => tr('menu_calendar')],
|
||||
'budget' => ['icon' => '💰', 'label' => tr('menu_budget')],
|
||||
'budget' => [
|
||||
'icon' => '💰',
|
||||
'label' => tr('menu_budget'),
|
||||
'mandatory_note' => tr('budget_mandatory_note'),
|
||||
'views' => [
|
||||
'budget_prev' => tr('budget_tab_prev'),
|
||||
'budget_epargne' => tr('budget_tab_savings'),
|
||||
'budget_provisions' => tr('budget_tab_provisions')
|
||||
]
|
||||
],
|
||||
'holidays' => ['icon' => '🏖️', 'label' => tr('menu_holidays')],
|
||||
'gifts' => ['icon' => '🎁', 'label' => tr('menu_gifts')],
|
||||
'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')],
|
||||
'memo' => ['icon' => '📝', 'label' => tr('menu_memo')],
|
||||
'todo' => ['icon' => '✅', 'label' => tr('menu_todo')],
|
||||
'liste' => ['icon' => '📝', 'label' => tr('menu_liste')],
|
||||
'liste' => ['icon' => '🛒', 'label' => tr('menu_liste')],
|
||||
'food' => [
|
||||
'icon' => '🍳',
|
||||
'label' => tr('mod_food_name'),
|
||||
'views' => [
|
||||
'food_meals' => tr('food_tab_meals'),
|
||||
'food_liste' => tr('food_tab_list')
|
||||
]
|
||||
],
|
||||
'calendar_ios' => ['icon' => '📱', 'label' => tr('menu_calendar_ios')],
|
||||
'printvault' => ['icon' => '🖨️', 'label' => tr('menu_printvault')],
|
||||
'planka' => ['icon' => '📋', 'label' => tr('menu_planka')],
|
||||
@@ -370,11 +439,34 @@ require __DIR__ . '/header.php';
|
||||
<input type="hidden" name="action" value="set_modules">
|
||||
<div class="pf-stack-md">
|
||||
<?php foreach ($allModules as $key => $mod): $active = in_array($key, $enabledMods); ?>
|
||||
<label class="pf-module-tile">
|
||||
<input type="checkbox" name="mod_<?= $key ?>" <?= $active ? 'checked' : '' ?> class="pf-checkbox-lg">
|
||||
<span class="pf-tile-icon"><?= $mod['icon'] ?></span>
|
||||
<span class="pf-tile-label"><?= $mod['label'] ?></span>
|
||||
</label>
|
||||
<div class="pf-module-card-wrapper">
|
||||
|
||||
<div class="pf-module-top-row">
|
||||
<label class="pf-module-tile-compact">
|
||||
<input type="checkbox" name="mod_<?= $key ?>" <?= $active ? 'checked' : '' ?> class="pf-checkbox-lg js-mod-checkbox">
|
||||
<span class="pf-tile-icon"><?= $mod['icon'] ?></span>
|
||||
<span class="pf-tile-label"><?= $mod['label'] ?></span>
|
||||
</label>
|
||||
|
||||
<?php if (!empty($mod['views'])): ?>
|
||||
<div class="pf-module-inline-pills">
|
||||
<?php foreach ($mod['views'] as $vKey => $vLabel): ?>
|
||||
<label class="pf-sub-module-pill">
|
||||
<input type="checkbox" name="view_<?= $vKey ?>" <?= in_array($vKey, $enabledViews) ? 'checked' : '' ?>>
|
||||
<span class="pf-pill-text"><?= htmlspecialchars($vLabel) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($mod['mandatory_note'])): ?>
|
||||
<div class="pf-module-info-footer">
|
||||
ℹ️ <?= $mod['mandatory_note'] ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
|
||||
@@ -382,42 +474,22 @@ require __DIR__ . '/header.php';
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$enabledModsForListe = $_SESSION['enabled_modules'] ?? [];
|
||||
if ($family_id && in_array('liste', $enabledModsForListe, 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">
|
||||
<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>
|
||||
</div>
|
||||
<button type="submit" class="pf-btn"><?= htmlspecialchars(tr('btn_save')) ?></button>
|
||||
</form>
|
||||
</section>
|
||||
<?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 +501,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 +517,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 +531,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 +542,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 +592,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 +658,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 +666,79 @@ require __DIR__ . '/header.php';
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<h3 class="pf-members-heading" style="margin-top: 2rem;">🧸 Profils rattachés</h3>
|
||||
<p class="pf-muted-note pf-muted-note--tight" style="margin-bottom: 1rem;">Membres gérés par le foyer (Enfants, Nounous, Proches) n'ayant pas d'accès direct.</p>
|
||||
<h3 class="pf-members-heading pf-mt-lg">🧸 <?= tr('settings_attached_profiles_title') ?></h3>
|
||||
<p class="pf-muted-note pf-muted-note--tight pf-mb-md"><?= tr('settings_attached_profiles_desc') ?></p>
|
||||
|
||||
<div class="pf-stack-sm" style="margin-bottom: 1.5rem;">
|
||||
<div class="pf-stack-sm" style="margin-bottom: 1.5rem;">
|
||||
<?php foreach ($attached_profiles as $p):
|
||||
$roleBadge = 'Inconnu';
|
||||
if (in_array($p['role'], ['child', 'enfant'])) $roleBadge = '👶 Enfant';
|
||||
elseif ($p['role'] === 'helper') $roleBadge = '💼 Intervenant';
|
||||
elseif ($p['role'] === 'relative') $roleBadge = '👵 Proche';
|
||||
$roleBadge = tr('role_unknown');
|
||||
if (in_array($p['role'], ['child', 'enfant'])) $roleBadge = tr('role_child_icon');
|
||||
elseif ($p['role'] === 'helper') $roleBadge = tr('role_helper_icon');
|
||||
elseif ($p['role'] === 'relative') $roleBadge = tr('role_relative_icon');
|
||||
|
||||
$pColor = htmlspecialchars($p['color'] ?? '#3b82f6');
|
||||
?>
|
||||
<div class="pf-member-card" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="pf-member-card" style="display: flex; justify-content: space-between; align-items: center; border-left: 4px solid <?= $pColor ?>;">
|
||||
<div>
|
||||
<strong><?= htmlspecialchars($p['name']) ?></strong>
|
||||
<span class="pf-muted-inline"><?= $roleBadge ?></span>
|
||||
</div>
|
||||
<form method="post" style="margin:0;">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="delete_attached_profile">
|
||||
<input type="hidden" name="profile_id" value="<?= $p['id'] ?>">
|
||||
<button type="submit" class="btn-icon-action delete" title="Supprimer" style="background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px;" onclick="return confirm('Supprimer définitivement ce profil ?')">🗑️</button>
|
||||
</form>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
|
||||
<div class="pf-color-dropdown">
|
||||
<button type="button" class="pf-color-trigger" style="background-color: <?= $pColor ?>;" title="<?= tr('change_color') ?>" onclick="toggleColorMenu(this)"></button>
|
||||
<div class="pf-color-menu">
|
||||
<?php foreach(['#ef4444', '#f97316', '#f59e0b', '#84cc16', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#d946ef', '#f43f5e', '#64748b'] as $c): ?>
|
||||
<button type="button" class="pf-color-swatch" style="background-color: <?= $c ?>;" onclick="updateAttachedProfileColor(<?= $p['id'] ?>, '<?= $c ?>', this)"></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" style="margin:0;">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="delete_attached_profile">
|
||||
<input type="hidden" name="profile_id" value="<?= $p['id'] ?>">
|
||||
<button type="submit" class="btn-icon-action delete" title="<?= tr('btn_delete') ?>" style="background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 4px;" onclick="return confirm('<?= addslashes(tr('confirm_delete_profile')) ?>')">🗑️</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($attached_profiles)): ?>
|
||||
<div class="pf-muted-note" style="padding: 10px; text-align: center; border: 1px dashed var(--border-light); border-radius: 8px;">Aucun profil rattaché.</div>
|
||||
<div class="pf-muted-note" style="padding: 10px; text-align: center; border: 1px dashed var(--border-light); border-radius: 8px;"><?= tr('settings_no_attached_profiles') ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<form method="post" style="display: flex; gap: 8px; background: var(--bg-soft); padding: 12px; border-radius: 8px; border: 1px dashed var(--border-light); align-items: flex-end;">
|
||||
<form method="post" style="display: flex; gap: 8px; background: var(--bg-soft); padding: 12px; border-radius: 8px; border: 1px dashed var(--border-light); align-items: flex-end; flex-wrap: wrap;">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="add_attached_profile">
|
||||
|
||||
<div class="pf-form-group" style="margin-bottom: 0; flex: 2;">
|
||||
<label class="pf-label" style="font-size: 0.8rem; margin-bottom: 4px;">Prénom</label>
|
||||
<input type="text" name="profile_name" class="pf-input" placeholder="Ex: Pol, Carole..." required>
|
||||
<div class="pf-form-group" style="margin-bottom: 0; flex: 2; min-width: 120px;">
|
||||
<label class="pf-label" style="font-size: 0.8rem; margin-bottom: 4px;"><?= tr('label_firstname') ?></label>
|
||||
<input type="text" name="profile_name" class="pf-input" placeholder="<?= tr('placeholder_firstname_ex') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="pf-form-group" style="margin-bottom: 0; flex: 1.5;">
|
||||
<label class="pf-label" style="font-size: 0.8rem; margin-bottom: 4px;">Rôle</label>
|
||||
<div class="pf-form-group" style="margin-bottom: 0; flex: 1.5; min-width: 120px;">
|
||||
<label class="pf-label" style="font-size: 0.8rem; margin-bottom: 4px;"><?= tr('label_role') ?></label>
|
||||
<select name="profile_role" class="pf-input" required>
|
||||
<option value="child">👶 Enfant</option>
|
||||
<option value="helper">💼 Intervenant</option>
|
||||
<option value="relative">👵 Proche</option>
|
||||
<option value="child"><?= tr('role_child_icon') ?></option>
|
||||
<option value="helper"><?= tr('role_helper_icon') ?></option>
|
||||
<option value="relative"><?= tr('role_relative_icon') ?></option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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;">Ajouter</button>
|
||||
<button type="submit" class="pf-btn pf-shrink-0" style="padding: 8px 16px; margin-bottom: 2px;"><?= tr('btn_add') ?></button>
|
||||
</form>
|
||||
|
||||
</section>
|
||||
@@ -632,7 +746,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 +774,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 +792,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'; ?>
|
||||
@@ -0,0 +1,158 @@
|
||||
const CACHE_NAME = "househub-static-v4";
|
||||
const API_CACHE_NAME = "househub-api-v1";
|
||||
const ASSETS_TO_CACHE = [
|
||||
"/offline.php",
|
||||
"/global.css",
|
||||
"/dark-mode.css",
|
||||
"/favicon.png",
|
||||
];
|
||||
|
||||
// --- Gestion de la file d'attente (IndexedDB) pour les actions hors ligne ---
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open("househub-offline-sync", 1);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains("sync-queue")) {
|
||||
db.createObjectStore("sync-queue", { autoIncrement: true });
|
||||
}
|
||||
};
|
||||
request.onsuccess = (event) => resolve(event.target.result);
|
||||
request.onerror = (event) => reject(event.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function dequeueSyncActions() {
|
||||
try {
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction("sync-queue", "readwrite");
|
||||
const store = transaction.objectStore("sync-queue");
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = async () => {
|
||||
const actions = request.result;
|
||||
for (const action of actions) {
|
||||
try {
|
||||
await fetch(action.url, {
|
||||
method: action.method,
|
||||
headers: action.headers,
|
||||
body: action.body,
|
||||
});
|
||||
|
||||
// Suppression si succès
|
||||
const delTx = db.transaction("sync-queue", "readwrite");
|
||||
delTx.objectStore("sync-queue").delete(action.id);
|
||||
} catch (err) {
|
||||
console.error("Erreur de synchronisation en arrière-plan :", err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Erreur ouverture DB dans le Service Worker", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Background Sync exclusif à Chromium
|
||||
self.addEventListener("sync", (event) => {
|
||||
if (event.tag === "househub-sync") {
|
||||
event.waitUntil(dequeueSyncActions());
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
// Utilise catch pour éviter qu'un fichier manquant (ex: favicon) fasse crasher l'installation
|
||||
return cache
|
||||
.addAll(ASSETS_TO_CACHE)
|
||||
.catch((err) => console.error("Erreur de cache initial", err));
|
||||
}),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME && cacheName !== API_CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
// Les requêtes non-GET (formulaires, uploads de fichiers type import CSV) ne doivent
|
||||
// jamais passer par le service worker : sur iOS (WebKit / Firefox iOS), refaire un fetch()
|
||||
// d'une navigation POST multipart perd le fichier, et la page revient sans rien.
|
||||
if (event.request.method !== "GET") return;
|
||||
|
||||
const requestUrl = new URL(event.request.url);
|
||||
|
||||
if (
|
||||
requestUrl.pathname.includes("/api/") ||
|
||||
requestUrl.pathname.includes("api.php")
|
||||
) {
|
||||
if (event.request.method === "GET") {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const responseClone = response.clone();
|
||||
caches
|
||||
.open(API_CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, responseClone));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(event.request)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const responseClone = response.clone();
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, responseClone));
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(event.request).then((cachedResponse) => {
|
||||
return cachedResponse || caches.match("/offline.php");
|
||||
});
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => {
|
||||
if (response) return response;
|
||||
return fetch(event.request)
|
||||
.then((networkResponse) => {
|
||||
if (
|
||||
networkResponse &&
|
||||
networkResponse.status === 200 &&
|
||||
(networkResponse.type === "basic" ||
|
||||
networkResponse.type === "cors")
|
||||
) {
|
||||
const responseToCache = networkResponse.clone();
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, responseToCache));
|
||||
}
|
||||
return networkResponse;
|
||||
})
|
||||
.catch(() => null);
|
||||
}),
|
||||
);
|
||||
});
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
// autoload.php @generated by Composer
|
||||
|
||||
if (PHP_VERSION_ID < 50600) {
|
||||
if (!headers_sent()) {
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
}
|
||||
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
|
||||
if (!ini_get('display_errors')) {
|
||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||
fwrite(STDERR, $err);
|
||||
} elseif (!headers_sent()) {
|
||||
echo $err;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException($err);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/composer/autoload_real.php';
|
||||
|
||||
return ComposerAutoloaderInit3ffbe28fb745b145fa2fcd19500ebf5e::getLoader();
|
||||
Vendored
+579
@@ -0,0 +1,579 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
/**
|
||||
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
|
||||
*
|
||||
* $loader = new \Composer\Autoload\ClassLoader();
|
||||
*
|
||||
* // register classes with namespaces
|
||||
* $loader->add('Symfony\Component', __DIR__.'/component');
|
||||
* $loader->add('Symfony', __DIR__.'/framework');
|
||||
*
|
||||
* // activate the autoloader
|
||||
* $loader->register();
|
||||
*
|
||||
* // to enable searching the include path (eg. for PEAR packages)
|
||||
* $loader->setUseIncludePath(true);
|
||||
*
|
||||
* In this example, if you try to use a class in the Symfony\Component
|
||||
* namespace or one of its children (Symfony\Component\Console for instance),
|
||||
* the autoloader will first look for the class under the component/
|
||||
* directory, and it will then fallback to the framework/ directory if not
|
||||
* found before giving up.
|
||||
*
|
||||
* This class is loosely based on the Symfony UniversalClassLoader.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
* @author Jordi Boggiano <j.boggiano@seld.be>
|
||||
* @see https://www.php-fig.org/psr/psr-0/
|
||||
* @see https://www.php-fig.org/psr/psr-4/
|
||||
*/
|
||||
class ClassLoader
|
||||
{
|
||||
/** @var \Closure(string):void */
|
||||
private static $includeFile;
|
||||
|
||||
/** @var string|null */
|
||||
private $vendorDir;
|
||||
|
||||
// PSR-4
|
||||
/**
|
||||
* @var array<string, array<string, int>>
|
||||
*/
|
||||
private $prefixLengthsPsr4 = array();
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private $prefixDirsPsr4 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr4 = array();
|
||||
|
||||
// PSR-0
|
||||
/**
|
||||
* List of PSR-0 prefixes
|
||||
*
|
||||
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
|
||||
*
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
private $prefixesPsr0 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr0 = array();
|
||||
|
||||
/** @var bool */
|
||||
private $useIncludePath = false;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private $classMap = array();
|
||||
|
||||
/** @var bool */
|
||||
private $classMapAuthoritative = false;
|
||||
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private $missingClasses = array();
|
||||
|
||||
/** @var string|null */
|
||||
private $apcuPrefix;
|
||||
|
||||
/**
|
||||
* @var array<string, self>
|
||||
*/
|
||||
private static $registeredLoaders = array();
|
||||
|
||||
/**
|
||||
* @param string|null $vendorDir
|
||||
*/
|
||||
public function __construct($vendorDir = null)
|
||||
{
|
||||
$this->vendorDir = $vendorDir;
|
||||
self::initializeIncludeClosure();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixes()
|
||||
{
|
||||
if (!empty($this->prefixesPsr0)) {
|
||||
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
|
||||
}
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixesPsr4()
|
||||
{
|
||||
return $this->prefixDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirs()
|
||||
{
|
||||
return $this->fallbackDirsPsr0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirsPsr4()
|
||||
{
|
||||
return $this->fallbackDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> Array of classname => path
|
||||
*/
|
||||
public function getClassMap()
|
||||
{
|
||||
return $this->classMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $classMap Class to filename map
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addClassMap(array $classMap)
|
||||
{
|
||||
if ($this->classMap) {
|
||||
$this->classMap = array_merge($this->classMap, $classMap);
|
||||
} else {
|
||||
$this->classMap = $classMap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix, either
|
||||
* appending or prepending to the ones previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 root directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr0
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$this->fallbackDirsPsr0,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$first = $prefix[0];
|
||||
if (!isset($this->prefixesPsr0[$first][$prefix])) {
|
||||
$this->prefixesPsr0[$first][$prefix] = $paths;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($prepend) {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixesPsr0[$first][$prefix]
|
||||
);
|
||||
} else {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$this->prefixesPsr0[$first][$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace, either
|
||||
* appending or prepending to the ones previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addPsr4($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
// Register directories for the root namespace.
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr4
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$this->fallbackDirsPsr4,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
|
||||
// Register directories for a new namespace.
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = $paths;
|
||||
} elseif ($prepend) {
|
||||
// Prepend directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixDirsPsr4[$prefix]
|
||||
);
|
||||
} else {
|
||||
// Append directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$this->prefixDirsPsr4[$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix,
|
||||
* replacing any others previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 base directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function set($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr0 = (array) $paths;
|
||||
} else {
|
||||
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace,
|
||||
* replacing any others previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setPsr4($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr4 = (array) $paths;
|
||||
} else {
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns on searching the include path for class files.
|
||||
*
|
||||
* @param bool $useIncludePath
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setUseIncludePath($useIncludePath)
|
||||
{
|
||||
$this->useIncludePath = $useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used to check if the autoloader uses the include path to check
|
||||
* for classes.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getUseIncludePath()
|
||||
{
|
||||
return $this->useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns off searching the prefix and fallback directories for classes
|
||||
* that have not been registered with the class map.
|
||||
*
|
||||
* @param bool $classMapAuthoritative
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setClassMapAuthoritative($classMapAuthoritative)
|
||||
{
|
||||
$this->classMapAuthoritative = $classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should class lookup fail if not found in the current class map?
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isClassMapAuthoritative()
|
||||
{
|
||||
return $this->classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
|
||||
*
|
||||
* @param string|null $apcuPrefix
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setApcuPrefix($apcuPrefix)
|
||||
{
|
||||
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The APCu prefix in use, or null if APCu caching is not enabled.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getApcuPrefix()
|
||||
{
|
||||
return $this->apcuPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers this instance as an autoloader.
|
||||
*
|
||||
* @param bool $prepend Whether to prepend the autoloader or not
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register($prepend = false)
|
||||
{
|
||||
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
|
||||
|
||||
if (null === $this->vendorDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($prepend) {
|
||||
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
|
||||
} else {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
self::$registeredLoaders[$this->vendorDir] = $this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this instance as an autoloader.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function unregister()
|
||||
{
|
||||
spl_autoload_unregister(array($this, 'loadClass'));
|
||||
|
||||
if (null !== $this->vendorDir) {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the given class or interface.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
* @return true|null True if loaded, null otherwise
|
||||
*/
|
||||
public function loadClass($class)
|
||||
{
|
||||
if ($file = $this->findFile($class)) {
|
||||
$includeFile = self::$includeFile;
|
||||
$includeFile($file);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the path to the file where the class is defined.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
*
|
||||
* @return string|false The path if found, false otherwise
|
||||
*/
|
||||
public function findFile($class)
|
||||
{
|
||||
// class map lookup
|
||||
if (isset($this->classMap[$class])) {
|
||||
return $this->classMap[$class];
|
||||
}
|
||||
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
|
||||
return false;
|
||||
}
|
||||
if (null !== $this->apcuPrefix) {
|
||||
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
|
||||
if ($hit) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
$file = $this->findFileWithExtension($class, '.php');
|
||||
|
||||
// Search for Hack files if we are running on HHVM
|
||||
if (false === $file && defined('HHVM_VERSION')) {
|
||||
$file = $this->findFileWithExtension($class, '.hh');
|
||||
}
|
||||
|
||||
if (null !== $this->apcuPrefix) {
|
||||
apcu_add($this->apcuPrefix.$class, $file);
|
||||
}
|
||||
|
||||
if (false === $file) {
|
||||
// Remember that this class does not exist.
|
||||
$this->missingClasses[$class] = true;
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently registered loaders keyed by their corresponding vendor directories.
|
||||
*
|
||||
* @return array<string, self>
|
||||
*/
|
||||
public static function getRegisteredLoaders()
|
||||
{
|
||||
return self::$registeredLoaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $class
|
||||
* @param string $ext
|
||||
* @return string|false
|
||||
*/
|
||||
private function findFileWithExtension($class, $ext)
|
||||
{
|
||||
// PSR-4 lookup
|
||||
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
|
||||
|
||||
$first = $class[0];
|
||||
if (isset($this->prefixLengthsPsr4[$first])) {
|
||||
$subPath = $class;
|
||||
while (false !== $lastPos = strrpos($subPath, '\\')) {
|
||||
$subPath = substr($subPath, 0, $lastPos);
|
||||
$search = $subPath . '\\';
|
||||
if (isset($this->prefixDirsPsr4[$search])) {
|
||||
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
|
||||
foreach ($this->prefixDirsPsr4[$search] as $dir) {
|
||||
if (file_exists($file = $dir . $pathEnd)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-4 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr4 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 lookup
|
||||
if (false !== $pos = strrpos($class, '\\')) {
|
||||
// namespaced class name
|
||||
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
|
||||
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
|
||||
} else {
|
||||
// PEAR-like class name
|
||||
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
|
||||
}
|
||||
|
||||
if (isset($this->prefixesPsr0[$first])) {
|
||||
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
|
||||
if (0 === strpos($class, $prefix)) {
|
||||
foreach ($dirs as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr0 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 include paths.
|
||||
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
private static function initializeIncludeClosure()
|
||||
{
|
||||
if (self::$includeFile !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope isolated include.
|
||||
*
|
||||
* Prevents access to $this/self from included files.
|
||||
*
|
||||
* @param string $file
|
||||
* @return void
|
||||
*/
|
||||
self::$includeFile = \Closure::bind(static function($file) {
|
||||
include $file;
|
||||
}, null, null);
|
||||
}
|
||||
}
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Composer\Semver\VersionParser;
|
||||
|
||||
/**
|
||||
* This class is copied in every Composer installed project and available to all
|
||||
*
|
||||
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
|
||||
*
|
||||
* To require its presence, you can require `composer-runtime-api ^2.0`
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class InstalledVersions
|
||||
{
|
||||
/**
|
||||
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
|
||||
* @internal
|
||||
*/
|
||||
private static $selfDir = null;
|
||||
|
||||
/**
|
||||
* @var mixed[]|null
|
||||
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
|
||||
*/
|
||||
private static $installed;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private static $installedIsLocalDir;
|
||||
|
||||
/**
|
||||
* @var bool|null
|
||||
*/
|
||||
private static $canGetVendors;
|
||||
|
||||
/**
|
||||
* @var array[]
|
||||
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static $installedByVendor = array();
|
||||
|
||||
/**
|
||||
* Returns a list of all package names which are present, either by being installed, replaced or provided
|
||||
*
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackages()
|
||||
{
|
||||
$packages = array();
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
$packages[] = array_keys($installed['versions']);
|
||||
}
|
||||
|
||||
if (1 === \count($packages)) {
|
||||
return $packages[0];
|
||||
}
|
||||
|
||||
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all package names with a specific type e.g. 'library'
|
||||
*
|
||||
* @param string $type
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackagesByType($type)
|
||||
{
|
||||
$packagesByType = array();
|
||||
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
foreach ($installed['versions'] as $name => $package) {
|
||||
if (isset($package['type']) && $package['type'] === $type) {
|
||||
$packagesByType[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $packagesByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package is installed
|
||||
*
|
||||
* This also returns true if the package name is provided or replaced by another package
|
||||
*
|
||||
* @param string $packageName
|
||||
* @param bool $includeDevRequirements
|
||||
* @return bool
|
||||
*/
|
||||
public static function isInstalled($packageName, $includeDevRequirements = true)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (isset($installed['versions'][$packageName])) {
|
||||
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package satisfies a version constraint
|
||||
*
|
||||
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
|
||||
*
|
||||
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
|
||||
*
|
||||
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
|
||||
* @param string $packageName
|
||||
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
|
||||
* @return bool
|
||||
*/
|
||||
public static function satisfies(VersionParser $parser, $packageName, $constraint)
|
||||
{
|
||||
$constraint = $parser->parseConstraints((string) $constraint);
|
||||
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
|
||||
|
||||
return $provided->matches($constraint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a version constraint representing all the range(s) which are installed for a given package
|
||||
*
|
||||
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
|
||||
* whether a given version of a package is installed, and not just whether it exists
|
||||
*
|
||||
* @param string $packageName
|
||||
* @return string Version constraint usable with composer/semver
|
||||
*/
|
||||
public static function getVersionRanges($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ranges = array();
|
||||
if (isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
|
||||
}
|
||||
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
|
||||
}
|
||||
if (array_key_exists('provided', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
|
||||
}
|
||||
|
||||
return implode(' || ', $ranges);
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['version'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getPrettyVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
|
||||
*/
|
||||
public static function getReference($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['reference'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['reference'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
|
||||
*/
|
||||
public static function getInstallPath($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
|
||||
*/
|
||||
public static function getRootPackage()
|
||||
{
|
||||
$installed = self::getInstalled();
|
||||
|
||||
return $installed[0]['root'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw installed.php data for custom implementations
|
||||
*
|
||||
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
|
||||
* @return array[]
|
||||
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
|
||||
*/
|
||||
public static function getRawData()
|
||||
{
|
||||
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
self::$installed = include __DIR__ . '/installed.php';
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
return self::$installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw data of all installed.php which are currently loaded for custom implementations
|
||||
*
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
public static function getAllRawData()
|
||||
{
|
||||
return self::getInstalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets you reload the static array from another file
|
||||
*
|
||||
* This is only useful for complex integrations in which a project needs to use
|
||||
* this class but then also needs to execute another project's autoloader in process,
|
||||
* and wants to ensure both projects have access to their version of installed.php.
|
||||
*
|
||||
* A typical case would be PHPUnit, where it would need to make sure it reads all
|
||||
* the data it needs from this class, then call reload() with
|
||||
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
|
||||
* the project in which it runs can then also use this class safely, without
|
||||
* interference between PHPUnit's dependencies and the project's dependencies.
|
||||
*
|
||||
* @param array[] $data A vendor/composer/installed.php data set
|
||||
* @return void
|
||||
*
|
||||
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
|
||||
*/
|
||||
public static function reload($data)
|
||||
{
|
||||
self::$installed = $data;
|
||||
self::$installedByVendor = array();
|
||||
|
||||
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
|
||||
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
|
||||
// so we have to assume it does not, and that may result in duplicate data being returned when listing
|
||||
// all installed packages for example
|
||||
self::$installedIsLocalDir = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
private static function getSelfDir()
|
||||
{
|
||||
if (self::$selfDir === null) {
|
||||
self::$selfDir = strtr(__DIR__, '\\', '/');
|
||||
}
|
||||
|
||||
return self::$selfDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static function getInstalled()
|
||||
{
|
||||
if (null === self::$canGetVendors) {
|
||||
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
|
||||
}
|
||||
|
||||
$installed = array();
|
||||
$copiedLocalDir = false;
|
||||
|
||||
if (self::$canGetVendors) {
|
||||
$selfDir = self::getSelfDir();
|
||||
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||
$vendorDir = strtr($vendorDir, '\\', '/');
|
||||
if (isset(self::$installedByVendor[$vendorDir])) {
|
||||
$installed[] = self::$installedByVendor[$vendorDir];
|
||||
} elseif (is_file($vendorDir.'/composer/installed.php')) {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require $vendorDir.'/composer/installed.php';
|
||||
self::$installedByVendor[$vendorDir] = $required;
|
||||
$installed[] = $required;
|
||||
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
|
||||
self::$installed = $required;
|
||||
self::$installedIsLocalDir = true;
|
||||
}
|
||||
}
|
||||
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
|
||||
$copiedLocalDir = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require __DIR__ . '/installed.php';
|
||||
self::$installed = $required;
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$installed !== array() && !$copiedLocalDir) {
|
||||
$installed[] = self::$installed;
|
||||
}
|
||||
|
||||
return $installed;
|
||||
}
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
|
||||
Copyright (c) Nils Adermann, Jordi Boggiano
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
// autoload_classmap.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||
);
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
// autoload_files.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php',
|
||||
);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
// autoload_namespaces.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'Smalot\\PdfParser\\' => array($vendorDir . '/smalot/pdfparser/src'),
|
||||
);
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
// autoload_psr4.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'),
|
||||
);
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
// autoload_real.php @generated by Composer
|
||||
|
||||
class ComposerAutoloaderInit3ffbe28fb745b145fa2fcd19500ebf5e
|
||||
{
|
||||
private static $loader;
|
||||
|
||||
public static function loadClassLoader($class)
|
||||
{
|
||||
if ('Composer\Autoload\ClassLoader' === $class) {
|
||||
require __DIR__ . '/ClassLoader.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Composer\Autoload\ClassLoader
|
||||
*/
|
||||
public static function getLoader()
|
||||
{
|
||||
if (null !== self::$loader) {
|
||||
return self::$loader;
|
||||
}
|
||||
|
||||
require __DIR__ . '/platform_check.php';
|
||||
|
||||
spl_autoload_register(array('ComposerAutoloaderInit3ffbe28fb745b145fa2fcd19500ebf5e', 'loadClassLoader'), true, true);
|
||||
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
||||
spl_autoload_unregister(array('ComposerAutoloaderInit3ffbe28fb745b145fa2fcd19500ebf5e', 'loadClassLoader'));
|
||||
|
||||
require __DIR__ . '/autoload_static.php';
|
||||
call_user_func(\Composer\Autoload\ComposerStaticInit3ffbe28fb745b145fa2fcd19500ebf5e::getInitializer($loader));
|
||||
|
||||
$loader->register(true);
|
||||
|
||||
$filesToLoad = \Composer\Autoload\ComposerStaticInit3ffbe28fb745b145fa2fcd19500ebf5e::$files;
|
||||
$requireFile = \Closure::bind(static function ($fileIdentifier, $file) {
|
||||
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
|
||||
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
|
||||
|
||||
require $file;
|
||||
}
|
||||
}, null, null);
|
||||
foreach ($filesToLoad as $fileIdentifier => $file) {
|
||||
$requireFile($fileIdentifier, $file);
|
||||
}
|
||||
|
||||
return $loader;
|
||||
}
|
||||
}
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
// autoload_static.php @generated by Composer
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
class ComposerStaticInit3ffbe28fb745b145fa2fcd19500ebf5e
|
||||
{
|
||||
public static $files = array (
|
||||
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php',
|
||||
);
|
||||
|
||||
public static $prefixLengthsPsr4 = array (
|
||||
'S' =>
|
||||
array (
|
||||
'Symfony\\Polyfill\\Mbstring\\' => 26,
|
||||
),
|
||||
);
|
||||
|
||||
public static $prefixDirsPsr4 = array (
|
||||
'Symfony\\Polyfill\\Mbstring\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring',
|
||||
),
|
||||
);
|
||||
|
||||
public static $prefixesPsr0 = array (
|
||||
'S' =>
|
||||
array (
|
||||
'Smalot\\PdfParser\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/smalot/pdfparser/src',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
public static $classMap = array (
|
||||
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||
);
|
||||
|
||||
public static function getInitializer(ClassLoader $loader)
|
||||
{
|
||||
return \Closure::bind(function () use ($loader) {
|
||||
$loader->prefixLengthsPsr4 = ComposerStaticInit3ffbe28fb745b145fa2fcd19500ebf5e::$prefixLengthsPsr4;
|
||||
$loader->prefixDirsPsr4 = ComposerStaticInit3ffbe28fb745b145fa2fcd19500ebf5e::$prefixDirsPsr4;
|
||||
$loader->prefixesPsr0 = ComposerStaticInit3ffbe28fb745b145fa2fcd19500ebf5e::$prefixesPsr0;
|
||||
$loader->classMap = ComposerStaticInit3ffbe28fb745b145fa2fcd19500ebf5e::$classMap;
|
||||
|
||||
}, null, ClassLoader::class);
|
||||
}
|
||||
}
|
||||
Vendored
+148
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"name": "smalot/pdfparser",
|
||||
"version": "v2.12.5",
|
||||
"version_normalized": "2.12.5.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/smalot/pdfparser.git",
|
||||
"reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/smalot/pdfparser/zipball/2cfa0d92bd557875c9f52a75fde0e8392302a354",
|
||||
"reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"ext-zlib": "*",
|
||||
"php": ">=7.1",
|
||||
"symfony/polyfill-mbstring": "^1.18"
|
||||
},
|
||||
"time": "2026-04-17T11:37:58+00:00",
|
||||
"type": "library",
|
||||
"installation-source": "dist",
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"Smalot\\PdfParser\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastien MALOT",
|
||||
"email": "sebastien@malot.fr"
|
||||
}
|
||||
],
|
||||
"description": "Pdf parser library. Can read and extract information from pdf file.",
|
||||
"homepage": "https://www.pdfparser.org",
|
||||
"keywords": [
|
||||
"extract",
|
||||
"parse",
|
||||
"parser",
|
||||
"pdf",
|
||||
"text"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/smalot/pdfparser/issues",
|
||||
"source": "https://github.com/smalot/pdfparser/tree/v2.12.5"
|
||||
},
|
||||
"install-path": "../smalot/pdfparser"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.38.2",
|
||||
"version_normalized": "1.38.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"provide": {
|
||||
"ext-mbstring": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "For best performance"
|
||||
},
|
||||
"time": "2026-05-27T06:59:30+00:00",
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"installation-source": "dist",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Mbstring\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for the Mbstring extension",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"mbstring",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"install-path": "../symfony/polyfill-mbstring"
|
||||
}
|
||||
],
|
||||
"dev": true,
|
||||
"dev-package-names": []
|
||||
}
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
<?php return array(
|
||||
'root' => array(
|
||||
'name' => '__root__',
|
||||
'pretty_version' => '1.0.0+no-version-set',
|
||||
'version' => '1.0.0.0',
|
||||
'reference' => null,
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
'dev' => true,
|
||||
),
|
||||
'versions' => array(
|
||||
'__root__' => array(
|
||||
'pretty_version' => '1.0.0+no-version-set',
|
||||
'version' => '1.0.0.0',
|
||||
'reference' => null,
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'smalot/pdfparser' => array(
|
||||
'pretty_version' => 'v2.12.5',
|
||||
'version' => '2.12.5.0',
|
||||
'reference' => '2cfa0d92bd557875c9f52a75fde0e8392302a354',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../smalot/pdfparser',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-mbstring' => array(
|
||||
'pretty_version' => 'v1.38.2',
|
||||
'version' => '1.38.2.0',
|
||||
'reference' => 'd3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/polyfill-mbstring',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
),
|
||||
);
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
// platform_check.php @generated by Composer
|
||||
|
||||
$issues = array();
|
||||
|
||||
if (!(PHP_VERSION_ID >= 70200)) {
|
||||
$issues[] = 'Your Composer dependencies require a PHP version ">= 7.2.0". You are running ' . PHP_VERSION . '.';
|
||||
}
|
||||
|
||||
if ($issues) {
|
||||
if (!headers_sent()) {
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
}
|
||||
if (!ini_get('display_errors')) {
|
||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
|
||||
} elseif (!headers_sent()) {
|
||||
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException(
|
||||
'Composer detected issues in your platform: ' . implode(' ', $issues)
|
||||
);
|
||||
}
|
||||
Vendored
+165
@@ -0,0 +1,165 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
# PDF parser
|
||||
|
||||
[](//packagist.org/packages/smalot/pdfparser)
|
||||

|
||||

|
||||
[](https://scrutinizer-ci.com/g/smalot/pdfparser/?branch=master)
|
||||
[](//packagist.org/packages/smalot/pdfparser)
|
||||
|
||||
The `smalot/pdfparser` is a standalone PHP package that provides various tools to extract data from PDF files.
|
||||
|
||||
This library is under **active maintenance**.
|
||||
There is no active development by the author of this library (at the moment), but we welcome any pull request adding/extending functionality!
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for further information about how to contribute.
|
||||
|
||||
## Features
|
||||
|
||||
- Load/parse objects and headers
|
||||
- Extract metadata (author, description, ...)
|
||||
- Extract text from ordered pages
|
||||
- Support of compressed PDFs
|
||||
- Support of MAC OS Roman charset encoding
|
||||
- Handling of hexa and octal encoding in text sections
|
||||
- Create custom configurations (see [CustomConfig.md](/doc/CustomConfig.md)).
|
||||
|
||||
Currently, secured documents and extracting form data are not supported.
|
||||
|
||||
## License
|
||||
|
||||
This library is under the [LGPLv3 license](https://github.com/smalot/pdfparser/blob/master/LICENSE.txt).
|
||||
|
||||
## Install
|
||||
|
||||
This library requires PHP 7.1+ since [v1](https://github.com/smalot/pdfparser/releases/tag/v1.0.0).
|
||||
You can install it via [Composer](https://getcomposer.org/):
|
||||
|
||||
```bash
|
||||
composer require smalot/pdfparser
|
||||
```
|
||||
|
||||
In case you can't use Composer, you can include `alt_autoload.php-dist`. It will include all required files automatically.
|
||||
|
||||
## Quick example
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Parse PDF file and build necessary objects.
|
||||
$parser = new \Smalot\PdfParser\Parser();
|
||||
$pdf = $parser->parseFile('/path/to/document.pdf');
|
||||
|
||||
$text = $pdf->getText();
|
||||
echo $text;
|
||||
```
|
||||
|
||||
Further usage information can be found [here](/doc/Usage.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation can be found in the [doc](/doc) folder.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "smalot/pdfparser",
|
||||
"description": "Pdf parser library. Can read and extract information from pdf file.",
|
||||
"keywords": ["PDF", "text", "parser", "parse", "extract"],
|
||||
"type": "library",
|
||||
"license": "LGPL-3.0",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastien MALOT",
|
||||
"email": "sebastien@malot.fr"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/smalot/pdfparser/issues"
|
||||
},
|
||||
"homepage": "https://www.pdfparser.org",
|
||||
"require": {
|
||||
"php": ">=7.1",
|
||||
"symfony/polyfill-mbstring": "^1.18",
|
||||
"ext-zlib": "*",
|
||||
"ext-iconv": "*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"Smalot\\PdfParser\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"PerformanceTests\\": "tests/Performance/",
|
||||
"PHPUnitTests\\": "tests/PHPUnit/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"process-timeout": 1200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Konrad Abicht <hi@inspirito.de>
|
||||
*
|
||||
* @date 2020-11-22
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser;
|
||||
|
||||
/**
|
||||
* This class contains configurations used in various classes. You can override them
|
||||
* manually, in case default values aren't working.
|
||||
*
|
||||
* @see https://github.com/smalot/pdfparser/issues/305
|
||||
*/
|
||||
class Config
|
||||
{
|
||||
private $fontSpaceLimit = -50;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $horizontalOffset = ' ';
|
||||
|
||||
/**
|
||||
* Represents: (NUL, HT, LF, FF, CR, SP)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $pdfWhitespaces = "\0\t\n\f\r ";
|
||||
|
||||
/**
|
||||
* Represents: (NUL, HT, LF, FF, CR, SP)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $pdfWhitespacesRegex = '[\0\t\n\f\r ]';
|
||||
|
||||
/**
|
||||
* Whether to retain raw image data as content or discard it to save memory
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $retainImageContent = true;
|
||||
|
||||
/**
|
||||
* Memory limit to use when de-compressing files, in bytes.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $decodeMemoryLimit = 0;
|
||||
|
||||
/**
|
||||
* Whether to include font id and size in dataTm array
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $dataTmFontInfoHasToBeIncluded = false;
|
||||
|
||||
/**
|
||||
* Whether to attempt to read PDFs even if they are marked as encrypted.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $ignoreEncryption = false;
|
||||
|
||||
public function getFontSpaceLimit()
|
||||
{
|
||||
return $this->fontSpaceLimit;
|
||||
}
|
||||
|
||||
public function setFontSpaceLimit($value)
|
||||
{
|
||||
$this->fontSpaceLimit = $value;
|
||||
}
|
||||
|
||||
public function getHorizontalOffset(): string
|
||||
{
|
||||
return $this->horizontalOffset;
|
||||
}
|
||||
|
||||
public function setHorizontalOffset($value): void
|
||||
{
|
||||
$this->horizontalOffset = $value;
|
||||
}
|
||||
|
||||
public function getPdfWhitespaces(): string
|
||||
{
|
||||
return $this->pdfWhitespaces;
|
||||
}
|
||||
|
||||
public function setPdfWhitespaces(string $pdfWhitespaces): void
|
||||
{
|
||||
$this->pdfWhitespaces = $pdfWhitespaces;
|
||||
}
|
||||
|
||||
public function getPdfWhitespacesRegex(): string
|
||||
{
|
||||
return $this->pdfWhitespacesRegex;
|
||||
}
|
||||
|
||||
public function setPdfWhitespacesRegex(string $pdfWhitespacesRegex): void
|
||||
{
|
||||
$this->pdfWhitespacesRegex = $pdfWhitespacesRegex;
|
||||
}
|
||||
|
||||
public function getRetainImageContent(): bool
|
||||
{
|
||||
return $this->retainImageContent;
|
||||
}
|
||||
|
||||
public function setRetainImageContent(bool $retainImageContent): void
|
||||
{
|
||||
$this->retainImageContent = $retainImageContent;
|
||||
}
|
||||
|
||||
public function getDecodeMemoryLimit(): int
|
||||
{
|
||||
return $this->decodeMemoryLimit;
|
||||
}
|
||||
|
||||
public function setDecodeMemoryLimit(int $decodeMemoryLimit): void
|
||||
{
|
||||
$this->decodeMemoryLimit = $decodeMemoryLimit;
|
||||
}
|
||||
|
||||
public function getDataTmFontInfoHasToBeIncluded(): bool
|
||||
{
|
||||
return $this->dataTmFontInfoHasToBeIncluded;
|
||||
}
|
||||
|
||||
public function setDataTmFontInfoHasToBeIncluded(bool $dataTmFontInfoHasToBeIncluded): void
|
||||
{
|
||||
$this->dataTmFontInfoHasToBeIncluded = $dataTmFontInfoHasToBeIncluded;
|
||||
}
|
||||
|
||||
public function getIgnoreEncryption(): bool
|
||||
{
|
||||
return $this->ignoreEncryption;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated this is a temporary workaround, don't rely on it
|
||||
* @see https://github.com/smalot/pdfparser/pull/653
|
||||
*/
|
||||
public function setIgnoreEncryption(bool $ignoreEncryption): void
|
||||
{
|
||||
$this->ignoreEncryption = $ignoreEncryption;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser;
|
||||
|
||||
use Smalot\PdfParser\Encoding\PDFDocEncoding;
|
||||
use Smalot\PdfParser\Exception\MissingCatalogException;
|
||||
|
||||
/**
|
||||
* Technical references :
|
||||
* - http://www.mactech.com/articles/mactech/Vol.15/15.09/PDFIntro/index.html
|
||||
* - http://framework.zend.com/issues/secure/attachment/12512/Pdf.php
|
||||
* - http://www.php.net/manual/en/ref.pdf.php#74211
|
||||
* - http://cpansearch.perl.org/src/JV/PostScript-Font-1.10.02/lib/PostScript/ISOLatin1Encoding.pm
|
||||
* - http://cpansearch.perl.org/src/JV/PostScript-Font-1.10.02/lib/PostScript/ISOLatin9Encoding.pm
|
||||
* - http://cpansearch.perl.org/src/JV/PostScript-Font-1.10.02/lib/PostScript/StandardEncoding.pm
|
||||
* - http://cpansearch.perl.org/src/JV/PostScript-Font-1.10.02/lib/PostScript/WinAnsiEncoding.pm
|
||||
*
|
||||
* Class Document
|
||||
*/
|
||||
class Document
|
||||
{
|
||||
/**
|
||||
* @var PDFObject[]
|
||||
*/
|
||||
protected $objects = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $dictionary = [];
|
||||
|
||||
/**
|
||||
* @var Header
|
||||
*/
|
||||
protected $trailer;
|
||||
|
||||
/**
|
||||
* @var array<mixed>
|
||||
*/
|
||||
protected $metadata = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $details;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->trailer = new Header([], $this);
|
||||
}
|
||||
|
||||
public function init()
|
||||
{
|
||||
$this->buildDictionary();
|
||||
|
||||
$this->buildDetails();
|
||||
|
||||
// Propagate init to objects.
|
||||
foreach ($this->objects as $object) {
|
||||
$object->getHeader()->init();
|
||||
$object->init();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dictionary based on type header field.
|
||||
*/
|
||||
protected function buildDictionary()
|
||||
{
|
||||
// Build dictionary.
|
||||
$this->dictionary = [];
|
||||
|
||||
foreach ($this->objects as $id => $object) {
|
||||
// Cache objects by type and subtype
|
||||
$type = $object->getHeader()->get('Type')->getContent();
|
||||
|
||||
if (null != $type) {
|
||||
if (!isset($this->dictionary[$type])) {
|
||||
$this->dictionary[$type] = [
|
||||
'all' => [],
|
||||
'subtype' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$this->dictionary[$type]['all'][$id] = $object;
|
||||
|
||||
$subtype = $object->getHeader()->get('Subtype')->getContent();
|
||||
if (null != $subtype) {
|
||||
if (!isset($this->dictionary[$type]['subtype'][$subtype])) {
|
||||
$this->dictionary[$type]['subtype'][$subtype] = [];
|
||||
}
|
||||
$this->dictionary[$type]['subtype'][$subtype][$id] = $object;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build details array.
|
||||
*/
|
||||
protected function buildDetails()
|
||||
{
|
||||
// Build details array.
|
||||
$details = [];
|
||||
|
||||
// Extract document info
|
||||
if ($this->trailer->has('Info')) {
|
||||
/** @var PDFObject $info */
|
||||
$info = $this->trailer->get('Info');
|
||||
// This could be an ElementMissing object, so we need to check for
|
||||
// the getHeader method first.
|
||||
if (null !== $info && method_exists($info, 'getHeader')) {
|
||||
$details = $info->getHeader()->getDetails();
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve the page count
|
||||
try {
|
||||
$pages = $this->getPages();
|
||||
$details['Pages'] = \count($pages);
|
||||
} catch (\Exception $e) {
|
||||
$details['Pages'] = 0;
|
||||
}
|
||||
|
||||
// Decode and repair encoded document properties
|
||||
foreach ($details as $key => $value) {
|
||||
if (\is_string($value)) {
|
||||
// If the string is already UTF-8 encoded, that means we only
|
||||
// need to repair Adobe's ham-fisted insertion of line-feeds
|
||||
// every ~127 characters, which doesn't seem to be multi-byte
|
||||
// safe
|
||||
if (mb_check_encoding($value, 'UTF-8')) {
|
||||
// Remove literal backslash + line-feed "\\r"
|
||||
$value = str_replace("\x5c\x0d", '', $value);
|
||||
|
||||
// Remove backslash plus bytes written into high part of
|
||||
// multibyte unicode character
|
||||
while (preg_match("/\x5c\x5c\xe0([\xb4-\xb8])(.)/", $value, $match)) {
|
||||
$diff = (\ord($match[1]) - 182) * 64;
|
||||
$newbyte = PDFDocEncoding::convertPDFDoc2UTF8(\chr(\ord($match[2]) + $diff));
|
||||
$value = preg_replace("/\x5c\x5c\xe0".$match[1].$match[2].'/', $newbyte, $value);
|
||||
}
|
||||
|
||||
// Remove bytes written into low part of multibyte unicode
|
||||
// character
|
||||
while (preg_match("/(.)\x9c\xe0([\xb3-\xb7])/", $value, $match)) {
|
||||
$diff = \ord($match[2]) - 181;
|
||||
$newbyte = \chr(\ord($match[1]) + $diff);
|
||||
$value = preg_replace('/'.$match[1]."\x9c\xe0".$match[2].'/', $newbyte, $value);
|
||||
}
|
||||
|
||||
// Remove this byte string that Adobe occasionally adds
|
||||
// between two single byte characters in a unicode string
|
||||
$value = str_replace("\xe5\xb0\x8d", '', $value);
|
||||
|
||||
$details[$key] = $value;
|
||||
} else {
|
||||
// If the string is just PDFDocEncoding, remove any line-feeds
|
||||
// and decode the whole thing.
|
||||
$value = str_replace("\\\r", '', $value);
|
||||
$details[$key] = PDFDocEncoding::convertPDFDoc2UTF8($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$details = array_merge($details, $this->metadata);
|
||||
|
||||
$this->details = $details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract XMP Metadata
|
||||
*/
|
||||
public function extractXMPMetadata(string $content): void
|
||||
{
|
||||
$xml = xml_parser_create();
|
||||
xml_parser_set_option($xml, \XML_OPTION_SKIP_WHITE, 1);
|
||||
|
||||
if (1 === xml_parse_into_struct($xml, $content, $values, $index)) {
|
||||
/*
|
||||
* short overview about the following code parts:
|
||||
*
|
||||
* The output of xml_parse_into_struct is a single dimensional array (= $values), and the $stack is a last-on,
|
||||
* first-off array of pointers to positions in $metadata, while iterating through it, that potentially turn the
|
||||
* results into a more intuitive multi-dimensional array. When an "open" XML tag is encountered,
|
||||
* we save the current $metadata context in the $stack, then create a child array of $metadata and
|
||||
* make that the current $metadata context. When a "close" XML tag is encountered, the operations are
|
||||
* reversed: the most recently added $metadata context from $stack (IOW, the parent of the current
|
||||
* element) is set as the current $metadata context.
|
||||
*/
|
||||
$metadata = [];
|
||||
$stack = [];
|
||||
foreach ($values as $val) {
|
||||
// Standardize to lowercase
|
||||
$val['tag'] = strtolower($val['tag']);
|
||||
|
||||
// Ignore structural x: and rdf: XML elements
|
||||
if (0 === strpos($val['tag'], 'x:')) {
|
||||
continue;
|
||||
} elseif (0 === strpos($val['tag'], 'rdf:') && 'rdf:li' != $val['tag']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch ($val['type']) {
|
||||
case 'open':
|
||||
// Create an array of list items
|
||||
if ('rdf:li' == $val['tag']) {
|
||||
$metadata[] = [];
|
||||
|
||||
// Move up one level in the stack
|
||||
$stack[\count($stack)] = &$metadata;
|
||||
$metadata = &$metadata[\count($metadata) - 1];
|
||||
} else {
|
||||
// Else create an array of named values
|
||||
$metadata[$val['tag']] = [];
|
||||
|
||||
// Move up one level in the stack
|
||||
$stack[\count($stack)] = &$metadata;
|
||||
$metadata = &$metadata[$val['tag']];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
if (isset($val['value'])) {
|
||||
// Assign a value to this list item
|
||||
if ('rdf:li' == $val['tag']) {
|
||||
$metadata[] = $val['value'];
|
||||
|
||||
// Else assign a value to this property
|
||||
} else {
|
||||
$metadata[$val['tag']] = $val['value'];
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'close':
|
||||
// If the value of this property is an array
|
||||
if (\is_array($metadata)) {
|
||||
// If the value is a single element array
|
||||
// where the element is of type string, use
|
||||
// the value of the first list item as the
|
||||
// value for this property
|
||||
if (1 == \count($metadata) && isset($metadata[0]) && \is_string($metadata[0])) {
|
||||
$metadata = $metadata[0];
|
||||
} elseif (0 == \count($metadata)) {
|
||||
// if the value is an empty array, set
|
||||
// the value of this property to the empty
|
||||
// string
|
||||
$metadata = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Move down one level in the stack
|
||||
$metadata = &$stack[\count($stack) - 1];
|
||||
unset($stack[\count($stack) - 1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only use this metadata if it's referring to a PDF
|
||||
if (!isset($metadata['dc:format']) || 'application/pdf' == $metadata['dc:format']) {
|
||||
// According to the XMP specifications: 'Conflict resolution
|
||||
// for separate packets that describe the same resource is
|
||||
// beyond the scope of this document.' - Section 6.1
|
||||
// Source: https://www.adobe.com/devnet/xmp.html
|
||||
// Source: https://github.com/adobe/XMP-Toolkit-SDK/blob/main/docs/XMPSpecificationPart1.pdf
|
||||
// So if there are multiple XMP blocks, just merge the values
|
||||
// of each found block over top of the existing values
|
||||
$this->metadata = array_merge($this->metadata, $metadata);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: remove this if-clause and its content when dropping PHP 7 support
|
||||
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
|
||||
// ref: https://www.php.net/manual/en/function.xml-parser-free.php
|
||||
xml_parser_free($xml);
|
||||
|
||||
// to avoid memory leaks; documentation said:
|
||||
// > it was necessary to also explicitly unset the reference to parser to avoid memory leaks
|
||||
unset($xml);
|
||||
}
|
||||
}
|
||||
|
||||
public function getDictionary(): array
|
||||
{
|
||||
return $this->dictionary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PDFObject[] $objects
|
||||
*/
|
||||
public function setObjects($objects = [])
|
||||
{
|
||||
$this->objects = (array) $objects;
|
||||
|
||||
$this->init();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PDFObject[]
|
||||
*/
|
||||
public function getObjects()
|
||||
{
|
||||
return $this->objects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PDFObject|Font|Page|Element|null
|
||||
*/
|
||||
public function getObjectById(string $id)
|
||||
{
|
||||
if (isset($this->objects[$id])) {
|
||||
return $this->objects[$id];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function hasObjectsByType(string $type, ?string $subtype = null): bool
|
||||
{
|
||||
return 0 < \count($this->getObjectsByType($type, $subtype));
|
||||
}
|
||||
|
||||
public function getObjectsByType(string $type, ?string $subtype = null): array
|
||||
{
|
||||
if (!isset($this->dictionary[$type])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (null != $subtype) {
|
||||
if (!isset($this->dictionary[$type]['subtype'][$subtype])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->dictionary[$type]['subtype'][$subtype];
|
||||
}
|
||||
|
||||
return $this->dictionary[$type]['all'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Font[]
|
||||
*/
|
||||
public function getFonts()
|
||||
{
|
||||
return $this->getObjectsByType('Font');
|
||||
}
|
||||
|
||||
public function getFirstFont(): ?Font
|
||||
{
|
||||
$fonts = $this->getFonts();
|
||||
if ([] === $fonts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return reset($fonts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Page[]
|
||||
*
|
||||
* @throws MissingCatalogException
|
||||
*/
|
||||
public function getPages()
|
||||
{
|
||||
if ($this->hasObjectsByType('Catalog')) {
|
||||
// Search for catalog to list pages.
|
||||
$catalogues = $this->getObjectsByType('Catalog');
|
||||
$catalogue = reset($catalogues);
|
||||
|
||||
/** @var Pages $object */
|
||||
$object = $catalogue->get('Pages');
|
||||
if (method_exists($object, 'getPages')) {
|
||||
return $object->getPages(true);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasObjectsByType('Pages')) {
|
||||
// Search for pages to list kids.
|
||||
$pages = [];
|
||||
|
||||
/** @var Pages[] $objects */
|
||||
$objects = $this->getObjectsByType('Pages');
|
||||
foreach ($objects as $object) {
|
||||
$pages = array_merge($pages, $object->getPages(true));
|
||||
}
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
if ($this->hasObjectsByType('Page')) {
|
||||
// Search for 'page' (unordered pages).
|
||||
$pages = $this->getObjectsByType('Page');
|
||||
|
||||
return array_values($pages);
|
||||
}
|
||||
|
||||
throw new MissingCatalogException('Missing catalog.');
|
||||
}
|
||||
|
||||
public function getText(?int $pageLimit = null): string
|
||||
{
|
||||
$texts = [];
|
||||
$pages = $this->getPages();
|
||||
|
||||
// Only use the first X number of pages if $pageLimit is set and numeric.
|
||||
if (\is_int($pageLimit) && 0 < $pageLimit) {
|
||||
$pages = \array_slice($pages, 0, $pageLimit);
|
||||
}
|
||||
|
||||
foreach ($pages as $index => $page) {
|
||||
/**
|
||||
* In some cases, the $page variable may be null.
|
||||
*/
|
||||
if (null === $page) {
|
||||
continue;
|
||||
}
|
||||
if ($text = trim($page->getText())) {
|
||||
$texts[] = $text;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n\n", $texts);
|
||||
}
|
||||
|
||||
public function getTrailer(): Header
|
||||
{
|
||||
return $this->trailer;
|
||||
}
|
||||
|
||||
public function setTrailer(Header $trailer)
|
||||
{
|
||||
$this->trailer = $trailer;
|
||||
}
|
||||
|
||||
public function getDetails(): array
|
||||
{
|
||||
return $this->details;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser;
|
||||
|
||||
use Smalot\PdfParser\Element\ElementArray;
|
||||
use Smalot\PdfParser\Element\ElementBoolean;
|
||||
use Smalot\PdfParser\Element\ElementDate;
|
||||
use Smalot\PdfParser\Element\ElementHexa;
|
||||
use Smalot\PdfParser\Element\ElementName;
|
||||
use Smalot\PdfParser\Element\ElementNull;
|
||||
use Smalot\PdfParser\Element\ElementNumeric;
|
||||
use Smalot\PdfParser\Element\ElementString;
|
||||
use Smalot\PdfParser\Element\ElementStruct;
|
||||
use Smalot\PdfParser\Element\ElementXRef;
|
||||
|
||||
/**
|
||||
* Class Element
|
||||
*/
|
||||
class Element
|
||||
{
|
||||
/**
|
||||
* @var Document|null
|
||||
*/
|
||||
protected $document;
|
||||
|
||||
protected $value;
|
||||
|
||||
public function __construct($value, ?Document $document = null)
|
||||
{
|
||||
$this->value = $value;
|
||||
$this->document = $document;
|
||||
}
|
||||
|
||||
public function init()
|
||||
{
|
||||
}
|
||||
|
||||
public function equals($value): bool
|
||||
{
|
||||
return $value == $this->value;
|
||||
}
|
||||
|
||||
public function contains($value): bool
|
||||
{
|
||||
if (\is_array($this->value)) {
|
||||
/** @var Element $val */
|
||||
foreach ($this->value as $val) {
|
||||
if ($val->equals($value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->equals($value);
|
||||
}
|
||||
|
||||
public function getContent()
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return (string) $this->value;
|
||||
}
|
||||
|
||||
public static function parse(string $content, ?Document $document = null, int &$position = 0)
|
||||
{
|
||||
$args = \func_get_args();
|
||||
$only_values = isset($args[3]) ? $args[3] : false;
|
||||
$content = trim($content);
|
||||
$values = [];
|
||||
|
||||
do {
|
||||
$old_position = $position;
|
||||
|
||||
if (!$only_values) {
|
||||
if (!preg_match('/\G\s*(?P<name>\/[A-Z#0-9\._]+)(?P<value>.*)/si', $content, $match, 0, $position)) {
|
||||
break;
|
||||
} else {
|
||||
$name = preg_replace_callback(
|
||||
'/#([0-9a-f]{2})/i',
|
||||
function ($m) {
|
||||
return \chr(base_convert($m[1], 16, 10));
|
||||
},
|
||||
ltrim($match['name'], '/')
|
||||
);
|
||||
$value = $match['value'];
|
||||
$position = strpos($content, $value, $position + \strlen($match['name']));
|
||||
}
|
||||
} else {
|
||||
$name = \count($values);
|
||||
$value = substr($content, $position);
|
||||
}
|
||||
|
||||
if ($element = ElementName::parse($value, $document, $position)) {
|
||||
$values[$name] = $element;
|
||||
} elseif ($element = ElementXRef::parse($value, $document, $position)) {
|
||||
$values[$name] = $element;
|
||||
} elseif ($element = ElementNumeric::parse($value, $document, $position)) {
|
||||
$values[$name] = $element;
|
||||
} elseif ($element = ElementStruct::parse($value, $document, $position)) {
|
||||
$values[$name] = $element;
|
||||
} elseif ($element = ElementBoolean::parse($value, $document, $position)) {
|
||||
$values[$name] = $element;
|
||||
} elseif ($element = ElementNull::parse($value, $document, $position)) {
|
||||
$values[$name] = $element;
|
||||
} elseif ($element = ElementDate::parse($value, $document, $position)) {
|
||||
$values[$name] = $element;
|
||||
} elseif ($element = ElementString::parse($value, $document, $position)) {
|
||||
$values[$name] = $element;
|
||||
} elseif ($element = ElementHexa::parse($value, $document, $position)) {
|
||||
$values[$name] = $element;
|
||||
} elseif ($element = ElementArray::parse($value, $document, $position)) {
|
||||
$values[$name] = $element;
|
||||
} else {
|
||||
$position = $old_position;
|
||||
break;
|
||||
}
|
||||
} while ($position < \strlen($content));
|
||||
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Document;
|
||||
use Smalot\PdfParser\Element;
|
||||
use Smalot\PdfParser\Header;
|
||||
use Smalot\PdfParser\PDFObject;
|
||||
|
||||
/**
|
||||
* Class ElementArray
|
||||
*/
|
||||
class ElementArray extends Element
|
||||
{
|
||||
public function __construct($value, ?Document $document = null)
|
||||
{
|
||||
parent::__construct($value, $document);
|
||||
}
|
||||
|
||||
public function getContent()
|
||||
{
|
||||
foreach ($this->value as $name => $element) {
|
||||
$this->resolveXRef($name);
|
||||
}
|
||||
|
||||
return parent::getContent();
|
||||
}
|
||||
|
||||
public function getRawContent(): array
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function getDetails(bool $deep = true): array
|
||||
{
|
||||
$values = [];
|
||||
$elements = $this->getContent();
|
||||
|
||||
foreach ($elements as $key => $element) {
|
||||
if ($element instanceof Header && $deep) {
|
||||
$values[$key] = $element->getDetails($deep);
|
||||
} elseif ($element instanceof PDFObject && $deep) {
|
||||
$values[$key] = $element->getDetails(false);
|
||||
} elseif ($element instanceof self) {
|
||||
if ($deep) {
|
||||
$values[$key] = $element->getDetails();
|
||||
}
|
||||
} elseif ($element instanceof Element && !($element instanceof self)) {
|
||||
$values[$key] = $element->getContent();
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return implode(',', $this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Element|PDFObject
|
||||
*/
|
||||
protected function resolveXRef(string $name)
|
||||
{
|
||||
if (($obj = $this->value[$name]) instanceof ElementXRef) {
|
||||
/** @var ElementXRef $obj */
|
||||
$obj = $this->document->getObjectById($obj->getId());
|
||||
$this->value[$name] = $obj;
|
||||
}
|
||||
|
||||
return $this->value[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo: These methods return mixed and mismatched types throughout the hierarchy
|
||||
*
|
||||
* @return bool|ElementArray
|
||||
*/
|
||||
public static function parse(string $content, ?Document $document = null, int &$offset = 0)
|
||||
{
|
||||
if (preg_match('/^\s*\[(?P<array>.*)/is', $content, $match)) {
|
||||
preg_match_all('/(.*?)(\[|\])/s', trim($content), $matches);
|
||||
|
||||
$level = 0;
|
||||
$sub = '';
|
||||
foreach ($matches[0] as $part) {
|
||||
$sub .= $part;
|
||||
$level += (false !== strpos($part, '[') ? 1 : -1);
|
||||
if ($level <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Removes 1 level [ and ].
|
||||
$sub = substr(trim($sub), 1, -1);
|
||||
$sub_offset = 0;
|
||||
$values = Element::parse($sub, $document, $sub_offset, true);
|
||||
|
||||
$offset += strpos($content, '[') + 1;
|
||||
// Find next ']' position
|
||||
$offset += \strlen($sub) + 1;
|
||||
|
||||
return new self($values, $document);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Document;
|
||||
use Smalot\PdfParser\Element;
|
||||
|
||||
/**
|
||||
* Class ElementBoolean
|
||||
*/
|
||||
class ElementBoolean extends Element
|
||||
{
|
||||
/**
|
||||
* @param string|bool $value
|
||||
*/
|
||||
public function __construct($value)
|
||||
{
|
||||
parent::__construct('true' == strtolower($value) || true === $value, null);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
public function equals($value): bool
|
||||
{
|
||||
return $this->getContent() === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|ElementBoolean
|
||||
*/
|
||||
public static function parse(string $content, ?Document $document = null, int &$offset = 0)
|
||||
{
|
||||
if (preg_match('/^\s*(?P<value>true|false)/is', $content, $match)) {
|
||||
$value = $match['value'];
|
||||
$offset += strpos($content, $value) + \strlen($value);
|
||||
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHPi, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Document;
|
||||
|
||||
/**
|
||||
* Class ElementDate
|
||||
*/
|
||||
class ElementDate extends ElementString
|
||||
{
|
||||
/**
|
||||
* @var array<int,string>
|
||||
*/
|
||||
protected static $formats = [
|
||||
4 => 'Y',
|
||||
6 => 'Ym',
|
||||
8 => 'Ymd',
|
||||
10 => 'YmdH',
|
||||
12 => 'YmdHi',
|
||||
14 => 'YmdHis',
|
||||
15 => 'YmdHise',
|
||||
17 => 'YmdHisO',
|
||||
18 => 'YmdHisO',
|
||||
19 => 'YmdHisO',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $format = 'c';
|
||||
|
||||
/**
|
||||
* @var \DateTime
|
||||
*/
|
||||
protected $value;
|
||||
|
||||
public function __construct($value)
|
||||
{
|
||||
if (!($value instanceof \DateTime)) {
|
||||
throw new \Exception('DateTime required.'); // FIXME: Sometimes strings are passed to this function
|
||||
}
|
||||
|
||||
parent::__construct($value);
|
||||
}
|
||||
|
||||
public function setFormat(string $format)
|
||||
{
|
||||
$this->format = $format;
|
||||
}
|
||||
|
||||
public function equals($value): bool
|
||||
{
|
||||
if ($value instanceof \DateTime) {
|
||||
$timestamp = $value->getTimeStamp();
|
||||
} else {
|
||||
$timestamp = strtotime($value);
|
||||
}
|
||||
|
||||
return $timestamp == $this->value->getTimeStamp();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return (string) $this->value->format($this->format);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|ElementDate
|
||||
*/
|
||||
public static function parse(string $content, ?Document $document = null, int &$offset = 0)
|
||||
{
|
||||
if (preg_match('/^\s*\(D\:(?P<name>.*?)\)/s', $content, $match)) {
|
||||
$name = $match['name'];
|
||||
$name = str_replace("'", '', $name);
|
||||
$date = false;
|
||||
|
||||
// Smallest format : Y
|
||||
// Full format : YmdHisP
|
||||
if (preg_match('/^\d{4}(\d{2}(\d{2}(\d{2}(\d{2}(\d{2}(Z(\d{2,4})?|[\+-]?\d{2}(\d{2})?)?)?)?)?)?)?$/', $name)) {
|
||||
if ($pos = strpos($name, 'Z')) {
|
||||
$name = substr($name, 0, $pos + 1);
|
||||
} elseif (18 == \strlen($name) && preg_match('/[^\+-]0000$/', $name)) {
|
||||
$name = substr($name, 0, -4).'+0000';
|
||||
}
|
||||
|
||||
$format = self::$formats[\strlen($name)];
|
||||
$date = \DateTime::createFromFormat($format, $name, new \DateTimeZone('UTC'));
|
||||
} else {
|
||||
// special cases
|
||||
if (preg_match('/^\d{1,2}-\d{1,2}-\d{4},?\s+\d{2}:\d{2}:\d{2}[\+-]\d{4}$/', $name)) {
|
||||
$name = str_replace(',', '', $name);
|
||||
$format = 'n-j-Y H:i:sO';
|
||||
$date = \DateTime::createFromFormat($format, $name, new \DateTimeZone('UTC'));
|
||||
}
|
||||
}
|
||||
|
||||
if (!$date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$offset += strpos($content, '(D:') + \strlen($match['name']) + 4; // 1 for '(D:' and ')'
|
||||
|
||||
return new self($date);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Document;
|
||||
|
||||
/**
|
||||
* Class ElementHexa
|
||||
*/
|
||||
class ElementHexa extends ElementString
|
||||
{
|
||||
/**
|
||||
* @return bool|ElementHexa|ElementDate
|
||||
*/
|
||||
public static function parse(string $content, ?Document $document = null, int &$offset = 0)
|
||||
{
|
||||
if (preg_match('/^\s*\<(?P<name>[A-F0-9]+)\>/is', $content, $match)) {
|
||||
$name = $match['name'];
|
||||
$offset += strpos($content, '<'.$name) + \strlen($name) + 2; // 1 for '>'
|
||||
// repackage string as standard
|
||||
$name = '('.self::decode($name).')';
|
||||
$element = ElementDate::parse($name, $document);
|
||||
|
||||
if (!$element) {
|
||||
$element = ElementString::parse($name, $document);
|
||||
}
|
||||
|
||||
return $element;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function decode(string $value): string
|
||||
{
|
||||
$text = '';
|
||||
|
||||
// Filter $value of non-hexadecimal characters
|
||||
$value = (string) preg_replace('/[^0-9a-f]/i', '', $value);
|
||||
|
||||
// Check for leading zeros (4-byte hexadecimal indicator), or
|
||||
// the BE BOM
|
||||
if ('00' === substr($value, 0, 2) || 'feff' === strtolower(substr($value, 0, 4))) {
|
||||
$value = (string) preg_replace('/^feff/i', '', $value);
|
||||
for ($i = 0, $length = \strlen($value); $i < $length; $i += 4) {
|
||||
$hex = substr($value, $i, 4);
|
||||
$text .= '&#'.str_pad(hexdec($hex), 4, '0', \STR_PAD_LEFT).';';
|
||||
}
|
||||
} else {
|
||||
// Otherwise decode this as 2-byte hexadecimal
|
||||
for ($i = 0, $length = \strlen($value); $i < $length; $i += 2) {
|
||||
$hex = substr($value, $i, 2);
|
||||
$text .= \chr(hexdec($hex));
|
||||
}
|
||||
}
|
||||
|
||||
$text = html_entity_decode($text, \ENT_NOQUOTES, 'UTF-8');
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Element;
|
||||
|
||||
/**
|
||||
* Class ElementMissing
|
||||
*/
|
||||
class ElementMissing extends Element
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(null, null);
|
||||
}
|
||||
|
||||
public function equals($value): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function contains($value): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getContent(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Document;
|
||||
use Smalot\PdfParser\Element;
|
||||
use Smalot\PdfParser\Font;
|
||||
|
||||
/**
|
||||
* Class ElementName
|
||||
*/
|
||||
class ElementName extends Element
|
||||
{
|
||||
public function __construct(string $value)
|
||||
{
|
||||
parent::__construct($value, null);
|
||||
}
|
||||
|
||||
public function equals($value): bool
|
||||
{
|
||||
return $value == $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|ElementName
|
||||
*/
|
||||
public static function parse(string $content, ?Document $document = null, int &$offset = 0)
|
||||
{
|
||||
if (preg_match('/^\s*\/([A-Z0-9\-\+,#\.]+)/is', $content, $match)) {
|
||||
$name = $match[1];
|
||||
$offset += strpos($content, $name) + \strlen($name);
|
||||
$name = Font::decodeEntities($name);
|
||||
|
||||
return new self($name);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Document;
|
||||
use Smalot\PdfParser\Element;
|
||||
|
||||
/**
|
||||
* Class ElementNull
|
||||
*/
|
||||
class ElementNull extends Element
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(null, null);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'null';
|
||||
}
|
||||
|
||||
public function equals($value): bool
|
||||
{
|
||||
return $this->getContent() === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|ElementNull
|
||||
*/
|
||||
public static function parse(string $content, ?Document $document = null, int &$offset = 0)
|
||||
{
|
||||
if (preg_match('/^\s*(null)/s', $content, $match)) {
|
||||
$offset += strpos($content, 'null') + \strlen('null');
|
||||
|
||||
return new self();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Document;
|
||||
use Smalot\PdfParser\Element;
|
||||
|
||||
/**
|
||||
* Class ElementNumeric
|
||||
*/
|
||||
class ElementNumeric extends Element
|
||||
{
|
||||
public function __construct(string $value)
|
||||
{
|
||||
parent::__construct((float) $value, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|ElementNumeric
|
||||
*/
|
||||
public static function parse(string $content, ?Document $document = null, int &$offset = 0)
|
||||
{
|
||||
if (preg_match('/^\s*(?P<value>\-?[0-9\.]+)/s', $content, $match)) {
|
||||
$value = $match['value'];
|
||||
$offset += strpos($content, $value) + \strlen($value);
|
||||
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Document;
|
||||
use Smalot\PdfParser\Element;
|
||||
use Smalot\PdfParser\Font;
|
||||
|
||||
/**
|
||||
* Class ElementString
|
||||
*/
|
||||
class ElementString extends Element
|
||||
{
|
||||
public function __construct($value)
|
||||
{
|
||||
parent::__construct($value, null);
|
||||
}
|
||||
|
||||
public function equals($value): bool
|
||||
{
|
||||
return $value == $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|ElementString
|
||||
*/
|
||||
public static function parse(string $content, ?Document $document = null, int &$offset = 0)
|
||||
{
|
||||
if (preg_match('/^\s*\((?P<name>.*)/s', $content, $match)) {
|
||||
$name = $match['name'];
|
||||
|
||||
// Find next ')' not escaped.
|
||||
$cur_start_text = $start_search_end = 0;
|
||||
while (false !== ($cur_start_pos = strpos($name, ')', $start_search_end))) {
|
||||
$cur_extract = substr($name, $cur_start_text, $cur_start_pos - $cur_start_text);
|
||||
preg_match('/(?P<escape>[\\\]*)$/s', $cur_extract, $match);
|
||||
if (!(\strlen($match['escape']) % 2)) {
|
||||
break;
|
||||
}
|
||||
$start_search_end = $cur_start_pos + 1;
|
||||
}
|
||||
|
||||
// Extract string.
|
||||
$name = substr($name, 0, (int) $cur_start_pos);
|
||||
$offset += strpos($content, '(') + $cur_start_pos + 2; // 2 for '(' and ')'
|
||||
$name = str_replace(
|
||||
['\\\\', '\\ ', '\\/', '\(', '\)', '\n', '\r', '\t'],
|
||||
['\\', ' ', '/', '(', ')', "\n", "\r", "\t"],
|
||||
$name
|
||||
);
|
||||
|
||||
// Decode string.
|
||||
$name = Font::decodeOctal($name);
|
||||
$name = Font::decodeEntities($name);
|
||||
$name = Font::decodeHexadecimal($name, false);
|
||||
$name = Font::decodeUnicode($name);
|
||||
|
||||
return new self($name);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Document;
|
||||
use Smalot\PdfParser\Element;
|
||||
use Smalot\PdfParser\Header;
|
||||
|
||||
/**
|
||||
* Class ElementStruct
|
||||
*/
|
||||
class ElementStruct extends Element
|
||||
{
|
||||
/**
|
||||
* @return false|Header
|
||||
*/
|
||||
public static function parse(string $content, ?Document $document = null, int &$offset = 0)
|
||||
{
|
||||
if (preg_match('/^\s*<<(?P<struct>.*)/is', $content)) {
|
||||
preg_match_all('/(.*?)(<<|>>)/s', trim($content), $matches);
|
||||
|
||||
$level = 0;
|
||||
$sub = '';
|
||||
foreach ($matches[0] as $part) {
|
||||
$sub .= $part;
|
||||
$level += (false !== strpos($part, '<<') ? 1 : -1);
|
||||
if ($level <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$offset += strpos($content, '<<') + \strlen(rtrim($sub));
|
||||
|
||||
// Removes '<<' and '>>'.
|
||||
$sub = trim((string) preg_replace('/^\s*<<(.*)>>\s*$/s', '\\1', $sub));
|
||||
|
||||
$position = 0;
|
||||
$elements = Element::parse($sub, $document, $position);
|
||||
|
||||
return new Header($elements, $document);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser\Element;
|
||||
|
||||
use Smalot\PdfParser\Document;
|
||||
use Smalot\PdfParser\Element;
|
||||
|
||||
/**
|
||||
* Class ElementXRef
|
||||
*/
|
||||
class ElementXRef extends Element
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->getContent();
|
||||
}
|
||||
|
||||
public function getObject()
|
||||
{
|
||||
return $this->document->getObjectById($this->getId());
|
||||
}
|
||||
|
||||
public function equals($value): bool
|
||||
{
|
||||
/**
|
||||
* In case $value is a number and $this->value is a string like 5_0
|
||||
*
|
||||
* Without this if-clause code like:
|
||||
*
|
||||
* $element = new ElementXRef('5_0');
|
||||
* $this->assertTrue($element->equals(5));
|
||||
*
|
||||
* would fail (= 5_0 and 5 are not equal in PHP 8.0+).
|
||||
*/
|
||||
if (
|
||||
true === is_numeric($value)
|
||||
&& true === \is_string($this->getContent())
|
||||
&& 1 === preg_match('/[0-9]+\_[0-9]+/', $this->getContent(), $matches)
|
||||
) {
|
||||
return (float) $this->getContent() == $value;
|
||||
}
|
||||
|
||||
$id = ($value instanceof self) ? $value->getId() : $value;
|
||||
|
||||
return $this->getId() == $id;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return '#Obj#'.$this->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|ElementXRef
|
||||
*/
|
||||
public static function parse(string $content, ?Document $document = null, int &$offset = 0)
|
||||
{
|
||||
if (preg_match('/^\s*(?P<id>[0-9]+\s+[0-9]+\s+R)/s', $content, $match)) {
|
||||
$id = $match['id'];
|
||||
$offset += strpos($content, $id) + \strlen($id);
|
||||
$id = str_replace(' ', '_', rtrim($id, ' R'));
|
||||
|
||||
return new self($id, $document);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file is part of the PdfParser library.
|
||||
*
|
||||
* @author Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* @date 2017-01-03
|
||||
*
|
||||
* @license LGPLv3
|
||||
*
|
||||
* @url <https://github.com/smalot/pdfparser>
|
||||
*
|
||||
* PdfParser is a pdf library written in PHP, extraction oriented.
|
||||
* Copyright (C) 2017 - Sébastien MALOT <sebastien@malot.fr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program.
|
||||
* If not, see <http://www.pdfparser.org/sites/default/LICENSE.txt>.
|
||||
*/
|
||||
|
||||
namespace Smalot\PdfParser;
|
||||
|
||||
use Smalot\PdfParser\Element\ElementNumeric;
|
||||
use Smalot\PdfParser\Encoding\EncodingLocator;
|
||||
use Smalot\PdfParser\Encoding\PostScriptGlyphs;
|
||||
use Smalot\PdfParser\Exception\EncodingNotFoundException;
|
||||
|
||||
/**
|
||||
* Class Encoding
|
||||
*/
|
||||
class Encoding extends PDFObject
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $encoding;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $differences;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $mapping;
|
||||
|
||||
public function init()
|
||||
{
|
||||
$this->mapping = [];
|
||||
$this->differences = [];
|
||||
$this->encoding = [];
|
||||
|
||||
if ($this->has('BaseEncoding')) {
|
||||
$this->encoding = EncodingLocator::getEncoding($this->getEncodingClass())->getTranslations();
|
||||
|
||||
// Build table including differences.
|
||||
$differences = $this->get('Differences')->getContent();
|
||||
$code = 0;
|
||||
|
||||
if (!\is_array($differences)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($differences as $difference) {
|
||||
/** @var ElementNumeric $difference */
|
||||
if ($difference instanceof ElementNumeric) {
|
||||
$code = $difference->getContent();
|
||||
continue;
|
||||
}
|
||||
|
||||
// ElementName
|
||||
$this->differences[$code] = $difference;
|
||||
if (\is_object($difference)) {
|
||||
$this->differences[$code] = $difference->getContent();
|
||||
}
|
||||
|
||||
// For the next char.
|
||||
++$code;
|
||||
}
|
||||
|
||||
$this->mapping = $this->encoding;
|
||||
foreach ($this->differences as $code => $difference) {
|
||||
/* @var string $difference */
|
||||
$this->mapping[$code] = $difference;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetails(bool $deep = true): array
|
||||
{
|
||||
$details = [];
|
||||
|
||||
$details['BaseEncoding'] = ($this->has('BaseEncoding') ? (string) $this->get('BaseEncoding') : 'Ansi');
|
||||
$details['Differences'] = ($this->has('Differences') ? (string) $this->get('Differences') : '');
|
||||
|
||||
$details += parent::getDetails($deep);
|
||||
|
||||
return $details;
|
||||
}
|
||||
|
||||
public function translateChar($dec): ?int
|
||||
{
|
||||
if (isset($this->mapping[$dec])) {
|
||||
$dec = $this->mapping[$dec];
|
||||
}
|
||||
|
||||
return PostScriptGlyphs::getCodePoint($dec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns encoding class name if available or empty string (only prior PHP 7.4).
|
||||
*
|
||||
* @throws \Exception On PHP 7.4+ an exception is thrown if encoding class doesn't exist.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
try {
|
||||
return $this->getEncodingClass();
|
||||
} catch (\Exception $e) {
|
||||
// prior to PHP 7.4 toString has to return an empty string.
|
||||
if (version_compare(\PHP_VERSION, '7.4.0', '<')) {
|
||||
return '';
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws EncodingNotFoundException
|
||||
*/
|
||||
protected function getEncodingClass(): string
|
||||
{
|
||||
// Load reference table charset.
|
||||
$baseEncoding = preg_replace('/[^A-Z0-9]/is', '', $this->get('BaseEncoding')->getContent());
|
||||
|
||||
// Check for empty BaseEncoding field value
|
||||
if (!\is_string($baseEncoding) || 0 == \strlen($baseEncoding)) {
|
||||
$baseEncoding = 'StandardEncoding';
|
||||
}
|
||||
|
||||
$className = '\\Smalot\\PdfParser\\Encoding\\'.$baseEncoding;
|
||||
|
||||
if (!class_exists($className)) {
|
||||
throw new EncodingNotFoundException('Missing encoding data for: "'.$baseEncoding.'".');
|
||||
}
|
||||
|
||||
return $className;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user