75 Commits
Author SHA1 Message Date
FeFeClochette 536f479423 fix holidfays
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-31 13:14:50 +02:00
FeFeClochette 685c649e7c update holidays photo
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-30 18:17:03 +02:00
FeFeClochette 074d8e12d2 fix
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-30 12:50:12 +02:00
FeFeClochette f748330a1b gps expense
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-29 14:58:50 +02:00
FeFeClochette ebdbee71db planning mobil
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-28 16:37:39 +02:00
FeFeClochette 659b286967 imprv roadtrip
Deploy HouseHub / deploy (push) Successful in 3s
2026-07-28 08:51:55 +02:00
FeFeClochette ac8ebf7328 update road trip
Deploy HouseHub / deploy (push) Successful in 1s
2026-07-24 17:55:56 +02:00
FeFeClochette 49d4ec7c35 update calendar
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-22 18:36:08 +02:00
FeFeClochette c48a6ba116 pf_holidays
Deploy HouseHub / deploy (push) Successful in 4s
2026-07-20 20:39:26 +02:00
FeFeClochette 20d6c9b7b0 provisions
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-07 21:26:44 +02:00
FeFeClochette d856d0a409 Deploy from parent_id to production
Deploy HouseHub / deploy (push) Successful in 4s
2026-07-01 10:30:34 +02:00
FeFeClochette 71ec8bd68a search note budget 2026-07-01 10:30:23 +02:00
FeFeClochette 86ef3ccbf2 Deploy from parent_id to production
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-24 21:32:27 +02:00
FeFeClochette e7aa3b595b fix CAT 2026-06-24 21:32:13 +02:00
percoandClaude Sonnet 4.6 3e98de9c18 fix(memo): supprime loading=lazy sur les images en vue note
Deploy HouseHub / deploy (push) Successful in 2s
loading="lazy" dans un conteneur parent avec overflow:hidden empêche
le browser de détecter les images comme visibles → elles ne chargent pas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:23:38 +02:00
FeFeClochette 6b2f83b68c fix saving
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-24 16:51:23 +02:00
FeFeClochette 7e24ca7612 fix save
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-24 16:47:54 +02:00
FeFeClochette 506cd64a44 order cat
Deploy HouseHub / deploy (push) Successful in 1s
2026-06-21 11:25:17 +02:00
FeFeClochette 041411b070 refresh auto settings
Deploy HouseHub / deploy (push) Successful in 1s
2026-06-21 11:15:51 +02:00
FeFeClochette fa6521f8f5 multi cat recap budget
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-21 10:57:24 +02:00
FeFeClochette 5618448602 carnet de voyage
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-21 09:17:15 +02:00
FeFeClochette 9d22dc29b4 size modale mobile
Deploy HouseHub / deploy (push) Successful in 1s
2026-06-20 19:13:18 +02:00
FeFeClochette b3a4614c59 itineraire waze / G / Apple
Deploy HouseHub / deploy (push) Successful in 1s
2026-06-20 19:08:54 +02:00
FeFeClochette 18740c5e1b temps / modale détail / drag / waze
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-20 19:01:18 +02:00
FeFeClochette 854c6388ba km parcouru
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-20 17:32:53 +02:00
FeFeClochette 1e090cf9ca opti save
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-20 13:05:50 +02:00
FeFeClochette ff83320f9a opti save
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-20 13:00:53 +02:00
FeFeClochette 557a1451de voyage garage
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-20 12:50:40 +02:00
FeFeClochette 03a97df8f1 garage conso html
Deploy HouseHub / deploy (push) Successful in 1s
2026-06-20 10:09:22 +02:00
FeFeClochette ad5a845f18 Auto-commit for deploy to staging - 2026-06-20 10:02:41
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-20 10:02:41 +02:00
FeFeClochette 142d4612a9 Submarine
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-19 21:37:20 +02:00
FeFeClochette 2e30569675 save event
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-19 12:30:26 +02:00
FeFeClochette f5c5320189 auto increment
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-19 11:58:43 +02:00
FeFeClochette 5b4f4c68b2 fix alerte
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-19 11:54:38 +02:00
FeFeClochette aad528d15b typhon
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-19 11:13:37 +02:00
FeFeClochette 89d8ad114b fix recap et suivi
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-17 16:55:34 +02:00
FeFeClochette f36bfa828e fix recap annuel
Deploy HouseHub / deploy (push) Successful in 1s
2026-06-17 16:06:59 +02:00
FeFeClochette f9aa368768 test recap
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-17 13:39:00 +02:00
FeFeClochette e73524b2ae fix epargne
Deploy HouseHub / deploy (push) Successful in 1s
2026-06-17 13:04:03 +02:00
FeFeClochette 96df90d372 fix migrate
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-17 12:40:18 +02:00
FeFeClochette d6e95c1b2a ouragan
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-17 12:27:29 +02:00
FeFeClochette 16b08dd03b fix solde theorique
Deploy HouseHub / deploy (push) Successful in 1s
2026-06-14 10:22:27 +02:00
FeFeClochette 6459c225ac settings calendar
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-11 17:57:09 +02:00
FeFeClochette 429a4ccb66 Deploy from parent_id to production
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-09 08:57:39 +02:00
FeFeClochette cd79bbb71d gift dynamique 2026-06-09 08:56:52 +02:00
FeFeClochette ecac9479d5 Deploy from parent_id to production
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-08 10:01:21 +02:00
FeFeClochette 429a964727 refonte socle 2026-06-08 10:00:56 +02:00
FeFeClochette 70462aa78b finish budget and kids 2026-06-04 17:01:45 +02:00
FeFeClochette afa6587eb3 Auto-commit for deploy to staging - 2026-06-04 15:04:00 2026-06-04 15:04:00 +02:00
FeFeClochette c26a369bb6 Deploy from parent_id to production
Deploy HouseHub / deploy (push) Successful in 2s
2026-06-04 14:26:53 +02:00
FeFeClochette 305b58653d fix migration 2026-06-04 14:26:04 +02:00
perco 8e3e7fe3d8 Merge pull request 'Deploiement staging' (#5) from deploiement-staging into main
Deploy HouseHub / deploy (push) Successful in 2s
Reviewed-on: #5
2026-06-04 14:14:19 +02:00
FeFeClochette 2c8bbbb3e1 Merge branch 'parent_id' of http://nas.percolouco.com:3500/perco/HouseHub into parent_id 2026-06-04 13:09:50 +02:00
FeFeClochette 0ff3c42d35 fix bêtises et update schema sql 2026-06-04 13:09:47 +02:00
perco ea04831451 fix(garage): tous les champs optionnels vides → NULL dans INSERT maintenance
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-29 15:05:11 +02:00
perco d9ae5308ea fix(garage): cost vide → null au lieu de chaîne vide (erreur DECIMAL SQL)
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-29 14:56:40 +02:00
perco 2c5fb9da6b Merge pull request 'Merge branch 'main' of http://nas.percolouco.com:3500/perco/HouseHub into parent_id' (#4) from parent_id into main
Deploy HouseHub / deploy (push) Successful in 2s
Reviewed-on: #4
2026-05-21 11:22:12 +02:00
perco 751082308a Merge pull request 'feat(voyage): base de données péages 2026 (24 680 paires, 5 opérateurs)' (#3) from main into parent_id
Reviewed-on: #3
2026-05-21 11:20:16 +02:00
FeFeClochette b44688a654 fix migrate
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-20 19:20:16 +02:00
FeFeClochette f8e2ad67ee refacto parent Calendrier budget et couleur profil.
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-20 17:56:25 +02:00
percoandClaude Sonnet 4.6 6aab112ecb feat(voyage): affichage détaillé des gares de péage par segment
Deploy HouseHub / deploy (push) Successful in 1s
- Chaque segment affiche maintenant la gare d'entrée et de sortie
- Affichage de l'opérateur (APRR, COFIROUTE, SANEF...)
- Style amélioré : distance + gares sur la même ligne
- Ajout du champ 'op' dans la réponse API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:23:34 +02:00
percoandClaude Sonnet 4.6 6e409fb3a1 feat(voyage): calculateur de coût de trajet avec péages et carburant
Deploy HouseHub / deploy (push) Successful in 1s
- GPS database (toll_gps.json): 414 péages géocodés via Nominatim (88.5% coverage, 468 plazas)
- API PHP (voyage/api.php): estimation péages + carburant via OSRM routing
  - Matching opérateur/entrée/sortie par proximité GPS (80km radius)
  - Fallback haversine si OSRM indisponible
- UI holidays/detail.php: panneau "Coût du trajet" (≥2 étapes)
  - Inputs conso L/100km et prix carburant configurables
  - Affichage distance, péages, carburant, total aller
  - Détail par segment en accordéon
- CSS: classes hol-cost-stat pour les stats chiffrées

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:17:21 +02:00
percoandClaude Sonnet 4.6 535f1b7dcf feat(voyage): base de données péages 2026 (24 680 paires, 5 opérateurs)
Deploy HouseHub / deploy (push) Successful in 2s
Données extraites depuis les PDFs officiels 2026 :
- APRR : 20 964 paires (réseau A6, A31, A36, A40...)
- Cofiroute : 1 296 paires (A10, A11, A28...)
- SANEF : 1 620 paires (A1, A2, A26...)
- AREA : 674 paires (A41, A43...)
- SAPN : 126 paires (A13, A29...)

Script de parsing : /tmp/build_tolls.py (à conserver pour mises à jour)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:54:26 +02:00
FeFeClochette 4130b03a68 reverse
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-20 12:17:37 +02:00
FeFeClochette 315e1b5fb1 headerfefe
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-20 12:12:12 +02:00
FeFeClochette b1d3db4892 Merge branch 'main' of http://nas.percolouco.com:3500/perco/HouseHub into parent_id 2026-05-20 12:12:03 +02:00
Cedric 99ed0e31de rollback
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-20 12:05:58 +02:00
Cedric a1e7bc62fd test
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-20 12:05:10 +02:00
percoandClaude Sonnet 4.6 3675f0c01a ci: ajout branches staging-perco et staging-fefe dans le workflow deploy
Deploy HouseHub / deploy (push) Successful in 1s
- Push sur staging-perco → deploy /opt/container/househub-perco
- Push sur staging-fefe  → deploy /opt/container/househub-fefe
- Push sur main          → deploy /opt/container/househub (production)
- Utiliser `git deploy <env>` pour envoyer sur le bon environnement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:52:12 +02:00
perco 836533fcf6 Merge pull request 'refacto : var currency and zone applied' (#2) from param_foyer into main
Deploy HouseHub / deploy (push) Successful in 1s
Reviewed-on: #2
2026-05-20 11:26:08 +02:00
FeFeClochette 5fe9046a63 gitignore extract source 2026-05-20 11:22:06 +02:00
FeFeClochette 5b70cd20e6 Merge branch 'main' of http://nas.percolouco.com:3500/perco/HouseHub into parent_id 2026-05-20 11:19:05 +02:00
FeFeClochette ed2d5a7c9e delete extract source
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-20 11:16:49 +02:00
percoandClaude Sonnet 4.6 6bbee271e3 fix(liste): couleur appliquée sur tout le tab (pas juste un point)
Deploy HouseHub / deploy (push) Successful in 2s
- Tab actif coloré : background + border = couleur choisie, texte blanc
- Tab inactif coloré : border + texte = couleur choisie
- Suppression du .liste-tab-dot inutilisé

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:08:42 +02:00
percoandClaude Sonnet 4.6 7b294f300a feat(liste): modal création/édition avec couleur et type de liste
Deploy HouseHub / deploy (push) Successful in 2s
- Remplacement du formulaire inline par un modal complet
- Color picker : 11 couleurs + option "sans couleur"
- Type de liste : Courses, To-do, Voyage, Travail, Maison, Santé, Loisirs, Autre
- Tab affiche un dot coloré + emoji du type
- Bouton "Supprimer" dans le modal d'édition (remplace le × dans le tab)
- DB : columns color + list_type sur pf_lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:05:59 +02:00
75 changed files with 44079 additions and 6263 deletions
+17 -2
View File
@@ -2,7 +2,7 @@ name: Deploy HouseHub
on: on:
push: push:
branches: [main] branches: [main, staging-perco, staging-fefe]
jobs: jobs:
deploy: deploy:
@@ -15,6 +15,21 @@ jobs:
username: perco username: perco
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
script: | script: |
BRANCH="${{ github.ref_name }}"
case "$BRANCH" in
main)
cd /opt/container/househub cd /opt/container/househub
git pull git pull
echo "✓ HouseHub mis à jour" echo "✓ Production mise à jour"
;;
staging-perco)
cd /opt/container/househub-perco
git pull origin staging-perco
echo "✓ Staging Perco mis à jour"
;;
staging-fefe)
cd /opt/container/househub-fefe
git pull origin staging-fefe
echo "✓ Staging Fefe mis à jour"
;;
esac
+2
View File
@@ -1,3 +1,5 @@
househub_db_data househub_db_data
.env .env
data/.hh_app_secret data/.hh_app_secret
extract_source.php
.htaccess
+9 -3
View File
@@ -151,12 +151,15 @@ L'architecture de HouseHub isole chaque foyer dans sa propre base de données (`
Afin de transformer complètement l'application héritée de Pacha Family en un produit générique et autonome pour chaque espace familial, les chantiers suivants doivent être menés. Afin de transformer complètement l'application héritée de Pacha Family en un produit générique et autonome pour chaque espace familial, les chantiers suivants doivent être menés.
🟢 Niveau 1 : Facile (Constantes et configurations globales) 🟢 Niveau 1 : Facile (Constantes et configurations globales)
[x] Configuration de base : Extraire la devise (CURRENCY) et la zone de vacances scolaires (ZONE_SCOLAIRE) du fichier config.php pour les stocker dans les paramètres d'espace en BDD. [x] Configuration de base : Extraire la devise (CURRENCY) et la zone de vacances scolaires (ZONE_SCOLAIRE) du fichier config.php pour les stocker dans les paramètres d'espace en BDD.
[x] Suppression des IDs statiques : Supprimer les IDs parents fixes (ID_ALEX = 2, ID_LAIA = 3) dans config.php au profit d'une lecture dynamique basée sur les utilisateurs réels de la famille connectée (liens via la table pf_people).
[x] Charte graphique dynamique : Remplacer les couleurs codées en dur (bleu/orange) par des sélecteurs natifs dans l'espace "Paramètres", en générant les variables CSS et leurs nuances (via color-mix()) à la volée.
[ ] Filtres API Éducation Nationale : Rendre dynamique la zone de l'API (zones LIKE '%Zone C%' dans family-calendar.js) pour s'adapter à la région de chaque foyer. [ ] Filtres API Éducation Nationale : Rendre dynamique la zone de l'API (zones LIKE '%Zone C%' dans family-calendar.js) pour s'adapter à la région de chaque foyer.
[ ] Identifiant Planka : Variabiliser le project_id Planka par défaut écrit en dur dans planka/api.php. [ ] Identifiant Planka : Variabiliser le project_id Planka par défaut écrit en dur dans planka/api.php.
[ ] Suppression des IDs statiques : Supprimer les IDs parents fixes (ID_ALEX = 2, ID_LAIA = 3) dans config.php au profit d'une lecture dynamique basée sur les utilisateurs réels de la famille connectée.
🟡 Niveau 2 : Intermédiaire (Listes et dictionnaires en dur) 🟡 Niveau 2 : Intermédiaire (Listes et dictionnaires en dur)
[ ] Gestion des membres (Cadeaux) : Dans gift-list.php, remplacer la liste d'enfants ($children) et d'adultes ($baseAdults, $extraAdults) codée en dur par une lecture dynamique des membres du foyer déclarés en BDD. [ ] Gestion des membres (Cadeaux) : Dans gift-list.php, remplacer la liste d'enfants ($children) et d'adultes ($baseAdults, $extraAdults) codée en dur par une lecture dynamique des membres du foyer déclarés en BDD.
[ ] Occasions festives (Cadeaux) : Rendre configurables les occasions spéciales (TIO, NOEL, ROIS, ANNIV, SANT). [ ] Occasions festives (Cadeaux) : Rendre configurables les occasions spéciales (TIO, NOEL, ROIS, ANNIV, SANT).
[ ] Configuration des catégories (Budget) : Dans budget/views/suivi.php, migrer le tableau $categoriesConfig (FMCG, Essence, École, etc.), leurs couleurs et leurs suggestions de magasins associés vers un stockage paramétrable. [ ] Configuration des catégories (Budget) : Dans budget/views/suivi.php, migrer le tableau $categoriesConfig (FMCG, Essence, École, etc.), leurs couleurs et leurs suggestions de magasins associés vers un stockage paramétrable.
@@ -164,7 +167,9 @@ Afin de transformer complètement l'application héritée de Pacha Family en un
[ ] Types de carburants (Garage) : Variabiliser la liste statique des carburants dans garage.php. [ ] Types de carburants (Garage) : Variabiliser la liste statique des carburants dans garage.php.
🔴 Niveau 3 : Complexe (Logique métier et Schéma SQL) 🔴 Niveau 3 : Complexe (Logique métier et Schéma SQL)
[ ] Refonte relationnelle de la répartition (Budget) : Modifier la structure de la table pf_alloc_values pour supprimer les colonnes figées amount_alex et amount_laia et basculer sur un modèle relationnel (alloc_id, user_id, amount) capable de gérer n'importe quel nombre d'adultes (1, 2, 3 ou plus).
[x] Refonte relationnelle du Budget (Prévisionnel) : Refonte totale de budget_prev.php. Modification de la table pf_alloc_values pour basculer sur des colonnes génériques (amount_p1, amount_p2) et suppression définitive de toute logique conditionnelle liée aux prénoms des concepteurs originaux.
[ ] Refonte globale de la répartition (Budget) : Étendre le modèle relationnel (alloc_id, user_id, amount) au reste de l'application pour gérer n'importe quel nombre d'adultes (1, 2, 3 ou plus).
[ ] Destinations de transferts (Budget) : Rendre dynamiques les cibles de virements prévisionnels (vers L.Pol, vers L.Pep, etc.) en fonction des comptes épargne créés par la famille. [ ] Destinations de transferts (Budget) : Rendre dynamiques les cibles de virements prévisionnels (vers L.Pol, vers L.Pep, etc.) en fonction des comptes épargne créés par la famille.
[ ] Onglets d'Épargne : Générer dynamiquement les sous-onglets de la vue epargne.php à partir des membres réels au lieu des blocs statiques Alex, Laia et Nens. [ ] Onglets d'Épargne : Générer dynamiquement les sous-onglets de la vue epargne.php à partir des membres réels au lieu des blocs statiques Alex, Laia et Nens.
[ ] Types de Garde (Calendrier) : Supprimer les types d'événements et compteurs codés en dur (OFF_CAROLE, EXTRA_OFF_CAROLE, CENTRE, AVIS, PEP_SICK) pour permettre à chaque foyer de définir ses propres modalités de garde et compteurs de congés associés. [ ] Types de Garde (Calendrier) : Supprimer les types d'événements et compteurs codés en dur (OFF_CAROLE, EXTRA_OFF_CAROLE, CENTRE, AVIS, PEP_SICK) pour permettre à chaque foyer de définir ses propres modalités de garde et compteurs de congés associés.
@@ -175,7 +180,8 @@ Afin de transformer complètement l'application héritée de Pacha Family en un
## 📌 Versioning ## 📌 Versioning
| Version | Changements | | Version | Changements |
| ------- | ------------------------------------------------------------------------------------------ | | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| v2.3.0 | Multi-tenant (p.2) : Refonte BDD Budget (p1, p2), suppression IDs statiques et thèmes de profils dynamiques (color-mix()) |
| v2.2.0 | Multi-tenant (p.1) : Paramètres dynamiques du foyer (€, Zone) via BDD + script migration | | v2.2.0 | Multi-tenant (p.1) : Paramètres dynamiques du foyer (€, Zone) via BDD + script migration |
| v2.1.0 | Module Planka : Kanban natif connecté à Planka via API proxy PHP | | v2.1.0 | Module Planka : Kanban natif connecté à Planka via API proxy PHP |
| v2.0.0 | Module PrintVault : viewer 3D STL/3MF/GCode, stockage local, trajectoires GCode illimitées | | v2.0.0 | Module PrintVault : viewer 3D STL/3MF/GCode, stockage local, trajectoires GCode illimitées |
+35 -27
View File
@@ -4,6 +4,18 @@ require __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/i18n.php'; require_once __DIR__ . '/includes/i18n.php';
require_login('/login.php'); require_login('/login.php');
// ============================================================================
// DIAGNOSTIC ONBOARDING (Vérifie si le budget est prêt à être utilisé)
// ============================================================================
$currentYear = date('Y');
$hasCategories = ((int)$pdo->query("SELECT COUNT(*) FROM pf_budget_categories")->fetchColumn() > 0);
$hasSalaries = ((int)$pdo->query("SELECT COUNT(*) FROM pf_salary_config WHERE year = $currentYear")->fetchColumn() > 0);
$hasAccounts = ((int)$pdo->query("SELECT COUNT(*) FROM pf_bank_accounts")->fetchColumn() > 0);
$hasBudgetItems = ((int)$pdo->query("SELECT COUNT(*) FROM pf_budget_items")->fetchColumn() > 0);
// Le minimum vital pour que ça tourne
$isBudgetSetupOk = ($hasCategories && $hasSalaries && $hasAccounts);
// Gestion de l'onglet actif (par défaut 'suivi') // Gestion de l'onglet actif (par défaut 'suivi')
$tab = $_GET['tab'] ?? 'suivi'; $tab = $_GET['tab'] ?? 'suivi';
@@ -15,8 +27,17 @@ require __DIR__ . '/header.php';
?> ?>
<div class="pf-container"> <div class="pf-container">
<div class="pf-hero" style="text-align: center; margin-bottom: 30px;"> <div class="pf-hero budget-hero">
<h1 style="margin-bottom: 20px;"><?= tr('budget_main_header') ?></h1> <div class="budget-title-group">
<h1><?= tr('budget_main_header') ?></h1>
<!-- Bouton avec séparation claire entre l'engrenage animable et l'alerte fixe -->
<button class="btn-header-settings" onclick="openBudgetSettings()" title="<?= tr('settings') ?>">
<span class="gear-icon-anim">⚙️</span>
<?php if (!$isBudgetSetupOk || !$hasBudgetItems): ?>
<span class="alert-dot">!</span>
<?php endif; ?>
</button>
</div>
<nav class="budget-tabs-container"> <nav class="budget-tabs-container">
<a href="?tab=suivi" class="tab-item <?= $tab == 'suivi' ? 'active' : '' ?>"> <a href="?tab=suivi" class="tab-item <?= $tab == 'suivi' ? 'active' : '' ?>">
@@ -38,13 +59,18 @@ require __DIR__ . '/header.php';
<span class="tab-icon">📊</span> <span class="tab-icon">📊</span>
<span><?= tr('budget_tab_recap') ?></span> <span><?= tr('budget_tab_recap') ?></span>
</a> </a>
<a href="?tab=provisions" class="tab-item <?= $tab == 'provisions' ? 'active' : '' ?>">
<span class="tab-icon">🧮</span>
<span><?= tr('budget_tab_provisions') ?></span>
</a>
</nav> </nav>
</div> </div>
<section class="pf-section"> <section class="pf-section">
<?php <?php
// ROUTAGE DYNAMIQUE $allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev', 'provisions'];
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev'];
if (in_array($tab, $allowedTabs)) { if (in_array($tab, $allowedTabs)) {
$viewPath = __DIR__ . "/modules/budget/views/" . $tab . ".php"; $viewPath = __DIR__ . "/modules/budget/views/" . $tab . ".php";
@@ -52,7 +78,7 @@ require __DIR__ . '/header.php';
if (file_exists($viewPath)) { if (file_exists($viewPath)) {
include $viewPath; include $viewPath;
} else { } else {
echo "<div style='text-align:center; padding:50px; color:#ef4444;'>"; echo "<div class='budget-error-msg'>";
echo "<h3>" . tr('budget_err_file_not_found') . "</h3>"; echo "<h3>" . tr('budget_err_file_not_found') . "</h3>";
echo "<p>" . sprintf(tr('budget_err_file_detail'), htmlspecialchars($tab)) . "</p>"; echo "<p>" . sprintf(tr('budget_err_file_detail'), htmlspecialchars($tab)) . "</p>";
echo "</div>"; echo "</div>";
@@ -63,26 +89,8 @@ require __DIR__ . '/header.php';
?> ?>
</section> </section>
</div> </div>
<script>
// --- AUTO-SCROLL DES ONGLETS MOBILES ---
document.addEventListener('DOMContentLoaded', () => {
const tabsContainer = document.querySelector('.budget-tabs-container');
const activeTab = document.querySelector('.tab-item.active');
// Si on a bien un conteneur d'onglets ET un onglet actif <?php
if (tabsContainer && activeTab) { require __DIR__ . '/modules/budget/views/settings_modal.php';
// On vérifie si le conteneur a un scroll horizontal possible require __DIR__ . '/footer.php';
if (tabsContainer.scrollWidth > tabsContainer.clientWidth) { ?>
// On calcule la position pour centrer l'onglet à l'écran
const scrollPos = activeTab.offsetLeft - (tabsContainer.offsetWidth / 2) + (activeTab.offsetWidth / 2);
// On fait glisser le menu doucement
tabsContainer.scrollTo({
left: scrollPos,
behavior: 'smooth'
});
}
}
});
</script>
<?php require __DIR__ . '/footer.php'; ?>
+1
View File
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS user_calendar_integrations (
calendar_url VARCHAR(1024) DEFAULT NULL, calendar_url VARCHAR(1024) DEFAULT NULL,
status VARCHAR(30) DEFAULT 'connected', status VARCHAR(30) DEFAULT 'connected',
last_sync_at DATETIME DEFAULT NULL, last_sync_at DATETIME DEFAULT NULL,
calendar_prefs_json TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_provider (user_id, provider), UNIQUE KEY uq_user_provider (user_id, provider),
+231 -69
View File
@@ -1,10 +1,43 @@
-- HouseHub — Schéma MySQL -- HouseHub — Schéma MySQL (Modèle pour une base famille)
-- Créer la base : CREATE DATABASE househub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4; SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0; SET FOREIGN_KEY_CHECKS = 0;
-- ─── Utilisateurs ───────────────────────────────────────────────────────────── -- ─── Configuration du foyer ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT '',
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
csv_mapping TEXT DEFAULT NULL,
cares_modes TEXT DEFAULT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '', 'C');
-- ────────────────────────────────────────────────────────────
-- Paramètres dynamiques des Modules (Key-Value)
-- ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS `pf_settings` (
`setting_key` varchar(50) NOT NULL,
`setting_value` text DEFAULT NULL,
`module` varchar(50) NOT NULL,
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`setting_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO `pf_settings` (`setting_key`, `setting_value`, `module`) VALUES
('calendar_default_view', 'month', 'calendar'),
('calendar_first_day', '1', 'calendar'),
('calendar_working_hours', '08:00-19:00', 'calendar'),
('budget_start_day', '1', 'budget'),
('budget_default_tab', 'dépenses', 'budget'),
('travel_default_transport', 'car', 'voyage'),
('travel_default_fuel_price', '1.85', 'voyage'),
('gifts_hide_purchased', '0', 'cadeaux'),
('gifts_default_sort', 'person', 'cadeaux'),
('gifts_budget_alert', '500', 'cadeaux');
-- ─── Utilisateurs (Legacy) ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_users ( CREATE TABLE IF NOT EXISTS pf_users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE, username VARCHAR(100) NOT NULL UNIQUE,
@@ -12,21 +45,16 @@ CREATE TABLE IF NOT EXISTS pf_users (
display_name VARCHAR(100) NOT NULL display_name VARCHAR(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Familles ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT '',
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Initialisation de la ligne unique par défaut
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '', 'C');
-- ─── Personnes ──────────────────────────────────────────────────────────────── -- ─── Personnes ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_people ( CREATE TABLE IF NOT EXISTS `pf_people` (
id INT AUTO_INCREMENT PRIMARY KEY, `id` int(11) NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL `name` varchar(100) NOT NULL,
`user_id` int(11) DEFAULT NULL,
`role` varchar(50) DEFAULT NULL,
`care_modes` varchar(255) DEFAULT NULL,
`color` varchar(7) DEFAULT '#0891b2',
`is_active` TINYINT(1) DEFAULT 1,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Calendrier familial ────────────────────────────────────────────────────── -- ─── Calendrier familial ──────────────────────────────────────────────────────
@@ -64,10 +92,29 @@ CREATE TABLE IF NOT EXISTS pf_leave_snapshots (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_person_leave_meta ( CREATE TABLE IF NOT EXISTS pf_person_leave_meta (
id INT AUTO_INCREMENT PRIMARY KEY, id INT(11) AUTO_INCREMENT PRIMARY KEY,
person_id INT NOT NULL UNIQUE, person_id INT(11) NOT NULL,
anniversary_date DATE leave_type VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; method VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'FIXED',
allowance DECIMAL(5,2) DEFAULT 0.00,
anniversary_date DATE DEFAULT NULL,
INDEX (person_id),
INDEX (leave_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ─── Types de Congés (Référentiel) ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_leave_types (
id INT(11) AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) COLLATE utf8mb4_general_ci NOT NULL,
label VARCHAR(100) COLLATE utf8mb4_general_ci NOT NULL,
default_allowance DECIMAL(5,2) DEFAULT 0.00,
reset_month INT(11) DEFAULT 1,
allow_carry_over TINYINT(1) DEFAULT 0,
UNIQUE KEY code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT IGNORE INTO pf_leave_types (code, label, default_allowance, reset_month, allow_carry_over) VALUES
('CP', 'Congés Payés', 25.00, 6, 0);
CREATE TABLE IF NOT EXISTS pf_calendar_weeks ( CREATE TABLE IF NOT EXISTS pf_calendar_weeks (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -84,6 +131,19 @@ CREATE TABLE IF NOT EXISTS pf_calendar_weeks (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Budget ─────────────────────────────────────────────────────────────────── -- ─── Budget ───────────────────────────────────────────────────────────────────
-- NOUVEAU : Table des comptes bancaires
CREATE TABLE IF NOT EXISTS pf_bank_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
account_type ENUM('checking', 'savings') DEFAULT 'checking',
owner_person_id INT DEFAULT NULL,
is_default TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (owner_person_id) REFERENCES pf_people(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_budget_items ( CREATE TABLE IF NOT EXISTS pf_budget_items (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -108,13 +168,27 @@ CREATE TABLE IF NOT EXISTS pf_expenses (
amount DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0,
import_ref VARCHAR(255) DEFAULT NULL, import_ref VARCHAR(255) DEFAULT NULL,
budget_item_id INT DEFAULT NULL, budget_item_id INT DEFAULT NULL,
holiday_id INT DEFAULT NULL holiday_id INT DEFAULT NULL,
salary_id INT DEFAULT NULL -- 🔥 AJOUT : Lien vers le salaire configuré
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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 ( CREATE TABLE IF NOT EXISTS pf_alloc_categories (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
target DECIMAL(10,2) DEFAULT 0, target DECIMAL(10,2) DEFAULT 0.00,
transfer_dest VARCHAR(50) DEFAULT NULL,
holiday_id INT DEFAULT NULL, holiday_id INT DEFAULT NULL,
sort_order INT DEFAULT 0 sort_order INT DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -123,9 +197,10 @@ CREATE TABLE IF NOT EXISTS pf_alloc_values (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
month_date VARCHAR(10) NOT NULL, month_date VARCHAR(10) NOT NULL,
cat_id INT NOT NULL, cat_id INT NOT NULL,
amount_alex DECIMAL(10,2) DEFAULT 0, person_id INT NOT NULL,
amount_laia DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0.00,
UNIQUE KEY uq_alloc (month_date, cat_id) UNIQUE KEY uq_alloc_person (month_date, cat_id, person_id),
FOREIGN KEY (person_id) REFERENCES pf_people(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_savings ( CREATE TABLE IF NOT EXISTS pf_savings (
@@ -134,7 +209,8 @@ CREATE TABLE IF NOT EXISTS pf_savings (
month_date VARCHAR(10) NOT NULL, month_date VARCHAR(10) NOT NULL,
category VARCHAR(100) NOT NULL, category VARCHAR(100) NOT NULL,
amount DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0,
holiday_id INT DEFAULT NULL holiday_id INT DEFAULT NULL,
UNIQUE KEY uq_savings (owner, month_date, category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_bank_snapshots ( CREATE TABLE IF NOT EXISTS pf_bank_snapshots (
@@ -158,9 +234,38 @@ CREATE TABLE IF NOT EXISTS pf_salary_config (
CREATE TABLE IF NOT EXISTS pf_import_rules ( CREATE TABLE IF NOT EXISTS pf_import_rules (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL UNIQUE, keyword VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL category VARCHAR(100) NOT NULL,
budget_item_id INT(11) NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `pf_advances` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`advance_date` date NOT NULL,
`payer` varchar(50) NOT NULL,
`description` varchar(255) NOT NULL,
`amount` decimal(10,2) DEFAULT 0.00,
`from_savings` tinyint(1) DEFAULT 0,
`is_resolved` tinyint(1) DEFAULT 0,
`created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ─── Budget Catégories (Dynamiques) ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_budget_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
label VARCHAR(100) NOT NULL,
type VARCHAR(50) NOT NULL,
color VARCHAR(20) DEFAULT '#ccc',
icon VARCHAR(20) DEFAULT '?'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- Insertion des catégories de base pour toute nouvelle famille
INSERT IGNORE INTO pf_budget_categories (code, label, type, color, icon) VALUES
('INCOME', 'Revenus', 'Income', '#10b981', '💵'),
('FIXED', 'Charges Fixes', 'Expense', '#ef4444', '🏢');
-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_notes ( CREATE TABLE IF NOT EXISTS pf_notes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
note_type VARCHAR(100) NOT NULL, note_type VARCHAR(100) NOT NULL,
@@ -169,6 +274,29 @@ CREATE TABLE IF NOT EXISTS pf_notes (
UNIQUE KEY uq_note (note_type, reference_id) UNIQUE KEY uq_note (note_type, reference_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content LONGTEXT DEFAULT '',
tags VARCHAR(1000) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FULLTEXT KEY ft_notes (title, content, tags)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
note_id INT NOT NULL,
type ENUM('image','file','url') NOT NULL DEFAULT 'file',
filename VARCHAR(255) DEFAULT NULL,
original_name VARCHAR(255) DEFAULT NULL,
url TEXT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Voyages / Holidays ─────────────────────────────────────────────────────── -- ─── Voyages / Holidays ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_holidays ( CREATE TABLE IF NOT EXISTS pf_holidays (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -179,7 +307,33 @@ CREATE TABLE IF NOT EXISTS pf_holidays (
status VARCHAR(50) DEFAULT 'draft', status VARCHAR(50) DEFAULT 'draft',
budget_food DECIMAL(10,2) DEFAULT 0, budget_food DECIMAL(10,2) DEFAULT 0,
budget_extra DECIMAL(10,2) DEFAULT 0, budget_extra DECIMAL(10,2) DEFAULT 0,
notes TEXT DEFAULT NULL notes TEXT DEFAULT NULL,
vehicle_id INT DEFAULT NULL,
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;
CREATE TABLE IF NOT EXISTS pf_holidays_items (
id INT AUTO_INCREMENT PRIMARY KEY,
holiday_id INT NOT NULL,
category VARCHAR(100),
name VARCHAR(255),
amount DECIMAL(10,2) DEFAULT 0,
is_paid TINYINT(1) DEFAULT 0,
location_name VARCHAR(255) DEFAULT NULL,
lat DECIMAL(10,7) DEFAULT NULL,
lng DECIMAL(10,7) DEFAULT NULL,
sort_order INT DEFAULT 0,
notes TEXT DEFAULT NULL,
item_date DATE DEFAULT NULL,
item_time TIME DEFAULT NULL,
step_type VARCHAR(20) DEFAULT 'stop',
step_start_date DATE DEFAULT NULL,
step_end_date DATE DEFAULT NULL,
duration INT DEFAULT NULL,
expense_context VARCHAR(20) DEFAULT NULL,
FOREIGN KEY (holiday_id) REFERENCES pf_holidays(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_holidays_items ( CREATE TABLE IF NOT EXISTS pf_holidays_items (
@@ -214,6 +368,17 @@ CREATE TABLE IF NOT EXISTS pf_holidays_ideas (
notes TEXT DEFAULT NULL notes TEXT DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `pf_holidays_attachments` (
`id` INT NOT NULL AUTO_INCREMENT,
`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,
PRIMARY KEY (`id`),
CONSTRAINT `fk_holiday_attachment` FOREIGN KEY (`holiday_id`) REFERENCES `pf_holidays`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS pf_geocode_cache ( CREATE TABLE IF NOT EXISTS pf_geocode_cache (
q_hash CHAR(64) PRIMARY KEY, q_hash CHAR(64) PRIMARY KEY,
q VARCHAR(255), q VARCHAR(255),
@@ -225,27 +390,42 @@ CREATE TABLE IF NOT EXISTS pf_geocode_cache (
-- ─── Cadeaux ────────────────────────────────────────────────────────────────── -- ─── Cadeaux ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_gifts ( CREATE TABLE IF NOT EXISTS pf_gifts (
id INT AUTO_INCREMENT PRIMARY KEY, id INT(11) AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL, year INT(11) NOT NULL,
adult_name VARCHAR(100) NOT NULL, adult_name VARCHAR(100) NOT NULL,
payer_name VARCHAR(100), payer_name VARCHAR(100) DEFAULT NULL,
child_name VARCHAR(100) NOT NULL, child_name VARCHAR(100) NOT NULL,
occasion VARCHAR(50) NOT NULL, occasion VARCHAR(50) NOT NULL,
gift_description TEXT NOT NULL, gift_description TEXT NOT NULL,
product_link VARCHAR(500) DEFAULT NULL, product_link VARCHAR(500) DEFAULT NULL,
amount DECIMAL(8,2) DEFAULT 0 amount DECIMAL(8,2) DEFAULT 0.00
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
SET FOREIGN_KEY_CHECKS = 1; CREATE TABLE IF NOT EXISTS pf_gift_occasions (
id INT(11) AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
icon VARCHAR(20) DEFAULT '🎁',
month_date VARCHAR(5) DEFAULT NULL,
is_active TINYINT(1) DEFAULT 1,
sort_order INT DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ─── Données initiales ──────────────────────────────────────────────────────── INSERT IGNORE INTO pf_gift_occasions (code, name, icon, month_date, is_active, sort_order) VALUES
-- Utilisateur admin (mot de passe : changeme → à modifier !) ('XMAS', 'Noël', '🎄', '12-25', 1, 1),
INSERT IGNORE INTO pf_users (id, username, password_hash, display_name) ('BIRTHDAY', 'Anniversaire', '🎂', NULL, 1, 2),
VALUES (1, 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin'); ('OTHER', 'Autre', '🎁', NULL, 1, 99);
-- Personnes (IDs fixes correspondant aux constantes dans config.php) -- Table des règles (Qui offre à Qui)
INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex'); CREATE TABLE IF NOT EXISTS pf_gift_rules (
INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia'); id INT(11) AUTO_INCREMENT PRIMARY KEY,
adult_person_id INT(11) NOT NULL,
child_person_id INT(11) NOT NULL,
occasion_id INT(11) NOT NULL,
INDEX (adult_person_id),
INDEX (child_person_id),
INDEX (occasion_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ─── Garage Manager ─────────────────────────────────────────────────────────── -- ─── Garage Manager ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_vehicles ( CREATE TABLE IF NOT EXISTS pf_vehicles (
@@ -257,6 +437,7 @@ CREATE TABLE IF NOT EXISTS pf_vehicles (
license_plate VARCHAR(50) DEFAULT NULL, license_plate VARCHAR(50) DEFAULT NULL,
vin VARCHAR(100) DEFAULT NULL, vin VARCHAR(100) DEFAULT NULL,
fuel_type VARCHAR(50) DEFAULT 'Essence', fuel_type VARCHAR(50) DEFAULT 'Essence',
consumption decimal(4,2) DEFAULT NULL COMMENT 'Consommation L/100km',
color VARCHAR(50) DEFAULT NULL, color VARCHAR(50) DEFAULT NULL,
purchase_date DATE DEFAULT NULL, purchase_date DATE DEFAULT NULL,
purchase_price DECIMAL(10,2) DEFAULT NULL, purchase_price DECIMAL(10,2) DEFAULT NULL,
@@ -321,30 +502,6 @@ CREATE TABLE IF NOT EXISTS pf_garage_documents (
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_memo_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content LONGTEXT DEFAULT '',
tags VARCHAR(1000) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FULLTEXT KEY ft_notes (title, content, tags)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
note_id INT NOT NULL,
type ENUM('image','file','url') NOT NULL DEFAULT 'file',
filename VARCHAR(255) DEFAULT NULL,
original_name VARCHAR(255) DEFAULT NULL,
url TEXT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Todo ───────────────────────────────────────────────────────────────────── -- ─── Todo ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_todo_lists ( CREATE TABLE IF NOT EXISTS pf_todo_lists (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -372,15 +529,17 @@ CREATE TABLE IF NOT EXISTS pf_todos (
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Module Liste (multi-listes) ────────────────────────────────────────────── -- ─── Module Liste (Courses partagées) ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_lists ( CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste', name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
color VARCHAR(20) DEFAULT NULL,
list_type VARCHAR(50) DEFAULT NULL,
position INT NOT NULL DEFAULT 0, position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0); INSERT IGNORE INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
CREATE TABLE IF NOT EXISTS pf_grocery_items ( CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -445,6 +604,7 @@ CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
external_uid VARCHAR(255) NOT NULL, external_uid VARCHAR(255) NOT NULL,
external_etag VARCHAR(255) DEFAULT NULL, external_etag VARCHAR(255) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL, calendar_url VARCHAR(1024) DEFAULT NULL,
external_href VARCHAR(2048) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_calendar_event (calendar_event_id), UNIQUE KEY uq_calendar_event (calendar_event_id),
@@ -496,3 +656,5 @@ CREATE TABLE IF NOT EXISTS pf_planka_config (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id) PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;
-105
View File
@@ -1,105 +0,0 @@
<?php
/**
* HouseHub OS - Source Extractor 🦙🚀
* Génère un fichier Markdown contenant tout le code source du projet.
* Idéal pour alimenter une GEM ou un LLM.
*/
// 1. 🚨 SÉCURITÉ : Ne jamais laisser ce fichier accessible publiquement sans protection !
// Appelle le script via : https://househub.nas.../extract_source.php?key=TON_MOT_DE_PASSE
$secretKey = 'mR83s7MmXbP$jer$9C4HnGkry6xhGL6';
if (!isset($_GET['key']) || $_GET['key'] !== $secretKey) {
http_response_code(403);
die("⛔ Accès refusé. Veuillez fournir la clé de sécurité.");
}
// Forcer l'affichage en texte brut dans le navigateur pour un copier-coller facile
header('Content-Type: text/plain; charset=utf-8');
$root = __DIR__;
// 2. CONFIGURATION DU FILTRAGE
// Dossiers à exclure (ne mets pas de slash au début)
$excludeDirs = [
'.git',
'.gitea',
'.devtools',
'vendor',
'node_modules',
'assets/img',
'modules/holidays/assets/img',
'modules/family-calendar/assets/img',
'modules/gift-list/assets/img'
];
// Fichiers spécifiques à exclure (Sécurité & Bruit)
$excludeFiles = [
'.env',
'.env.example',
'extract_source.php', // On s'exclut soi-même
'pachafamily_source.md',
'docker-compose.override.yml'
];
// Extensions autorisées (on ignore les images, pdf, zip, etc.)
$allowedExtensions = ['php', 'js', 'css', 'sql', 'json', 'yml', 'yaml', 'md', 'sh'];
// 3. INITIALISATION DU PARCOURS
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$report = "# 🦙 Source Code HouseHub OS\n\n";
$report .= "> *Généré le " . date('Y-m-d H:i:s') . "*\n\n";
$report .= "---\n\n";
// 4. BOUCLE D'EXTRACTION
foreach ($iterator as $file) {
if ($file->isDir()) continue;
$filePath = $file->getPathname();
$relativePath = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
$relativePath = str_replace('\\', '/', $relativePath); // Uniformisation Windows/Linux
// Extraction de l'extension
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
// A. Filtrage par dossier
$skip = false;
foreach ($excludeDirs as $dir) {
// Si le chemin commence par ce dossier, ou contient /ce_dossier/
if (strpos($relativePath, $dir . '/') === 0 || strpos($relativePath, '/' . $dir . '/') !== false) {
$skip = true;
break;
}
}
if ($skip) continue;
// B. Filtrage par nom de fichier
if (in_array(basename($filePath), $excludeFiles)) continue;
// C. Filtrage par extension
if (!in_array($ext, $allowedExtensions)) continue;
// 5. LECTURE ET FORMATAGE
$content = file_get_contents($filePath);
if ($content === false) {
$content = "// Erreur lors de la lecture de ce fichier.";
}
// Ajustement du tag de langage pour le Markdown
$lang = $ext;
if ($ext === 'js') $lang = 'javascript';
if ($ext === 'yml') $lang = 'yaml';
$report .= "### 📄 Fichier : `$relativePath`\n";
$report .= "```$lang\n";
$report .= trim($content) . "\n";
$report .= "```\n\n---\n\n";
}
// 6. SORTIE
echo $report;
+306 -85
View File
@@ -8,6 +8,75 @@ require_login();
require __DIR__ . '/includes/db.php'; require __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/i18n.php'; require_once __DIR__ . '/includes/i18n.php';
// 1. Récupération de toutes les personnes
$stmtPeople = $pdo->query("SELECT id, name, user_id, role, color, care_modes FROM pf_people WHERE is_active = 1 ORDER BY id ASC");
$familyPeople = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
$parents = [];
$kids = [];
$helpers = [];
foreach ($familyPeople as $p) {
$role = strtolower($p['role'] ?? '');
if ($role === 'parent') {
$parents[] = $p;
} elseif ($role === 'enfant' || $role === 'child') {
$p['modes'] = json_decode($p['care_modes'] ?? '[]', true) ?: [];
$kids[] = $p;
} elseif ($role === 'helper' || $role === 'nounou') {
$helpers[] = $p; // 🟢 On stocke les intervenants
}
}
// 2. Modes de garde (globaux du foyer, pour synchroniser avec le JS)
$stmtFoyer = $pdo->query("SELECT care_modes, zone_scolaire FROM pf_foyer_settings LIMIT 1");
$foyerData = $stmtFoyer->fetch(PDO::FETCH_ASSOC);
$activeCareModes = json_decode($foyerData['care_modes'] ?? '[]', true);
if (!is_array($activeCareModes)) $activeCareModes = [];
// 3. Matrice de congés dynamique (pour les en-têtes et la modale)
$stmtLeaves = $pdo->query("SELECT person_id, leave_type FROM pf_person_leave_meta ORDER BY id ASC");
$dbLeaves = $stmtLeaves->fetchAll(PDO::FETCH_ASSOC);
$leaveMatrix = [];
$allLeaveTypes = [];
foreach ($dbLeaves as $l) {
$leaveMatrix[$l['person_id']][] = $l['leave_type'];
$allLeaveTypes[$l['leave_type']] = true;
}
$allLeaveTypes = array_keys($allLeaveTypes);
// ==========================================
// VÉRIFICATIONS D'INTÉGRITÉ (ALERTES)
// ==========================================
$calendarAlerts = [];
// 1. Aucun membre adulte/parent configuré
if (empty($parents)) {
$calendarAlerts[] = "<strong>Aucun adulte configuré.</strong> Rendez-vous dans les Paramètres Généraux de HouseHub pour ajouter les membres du foyer.";
}
// 2. Enfants présents, mais aucun mode de garde global défini
if (!empty($kids) && empty($activeCareModes)) {
$calendarAlerts[] = "<strong>Modes de garde manquants.</strong> Vous avez des enfants, mais aucun mode de garde n'est défini. Cliquez sur l'icône ⚙️ (Paramètres du calendrier) pour les ajouter.";
}
// 3. Référentiel des types de congés vide
try {
$countLeaveTypes = $pdo->query("SELECT COUNT(*) FROM pf_leave_types")->fetchColumn();
if ($countLeaveTypes == 0) {
$calendarAlerts[] = "<strong>Types de congés manquants.</strong> Le catalogue des congés (CP, RTT...) est vide. Ajoutez-les dans les paramètres généraux.";
}
} catch (Exception $e) {
// Si la table n'existe pas encore
$calendarAlerts[] = "<strong>Table des congés manquante.</strong> Veuillez mettre à jour la base de données.";
}
// 4. Zone scolaire manquante (si tu l'as stockée dans pf_foyer_settings)
$zoneScolaire = $foyerData['zone_scolaire'] ?? null;
if (empty($zoneScolaire)) {
$calendarAlerts[] = "<strong>Zone scolaire non définie.</strong> Cliquez sur l'icône ⚙️ pour configurer votre zone afin d'afficher les vacances scolaires.";
}
$pageTitle = tr('fc_page_title'); $pageTitle = tr('fc_page_title');
$activePage = "family-calendar"; $activePage = "family-calendar";
$mainClass = "pf-family-calendar"; $mainClass = "pf-family-calendar";
@@ -17,10 +86,18 @@ require __DIR__ . '/header.php';
?> ?>
<div class="pf-container" style="max-width:100%; padding:0;"> <div class="pf-container" style="max-width:100%; padding:0;">
<div class="fc-header-row"> <div class="fc-header-row" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; margin-bottom: 24px;">
<h1><?= tr('fc_main_header') ?></h1>
<div style="display: flex; gap: 10px;"> <!-- Bloc Titre + Roue crantée -->
<div style="display: flex; align-items: center; gap: 12px;">
<h1 style="margin: 0;"><?= tr('fc_main_header') ?></h1>
<button id="btnOpenCalendarSettings" class="btn-settings-gear" title="Paramètres du calendrier" style="font-size: 1.4rem; padding: 4px;">
⚙️
</button>
</div>
<!-- Boutons d'actions -->
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button id="btnOpenSnapshotModal" class="pf-btn-icon-text"> <button id="btnOpenSnapshotModal" class="pf-btn-icon-text">
<span class="icon">⚖️</span> <span class="icon">⚖️</span>
<span class="text"><?= tr('fc_btn_correct_balances') ?></span> <span class="text"><?= tr('fc_btn_correct_balances') ?></span>
@@ -32,6 +109,20 @@ require __DIR__ . '/header.php';
</div> </div>
</div> </div>
<?php if (!empty($calendarAlerts)): ?>
<div class="pf-alert pf-alert-warning" style="background: var(--bg-warning, #fffbeb); border: 1px solid var(--border-warning, #fcd34d); color: var(--text-warning, #92400e); padding: 16px; border-radius: 8px; margin-bottom: 24px; display: flex; gap: 12px; align-items: flex-start;">
<div style="font-size: 1.5rem; line-height: 1;">⚠️</div>
<div>
<h4 style="margin: 0 0 8px 0; font-size: 1.05rem;">Configuration incomplète</h4>
<ul style="margin: 0; padding-left: 18px; font-size: 0.95rem; line-height: 1.5;">
<?php foreach ($calendarAlerts as $alert): ?>
<li style="margin-bottom: 4px;"><?= $alert ?></li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php endif; ?>
<div id="modalHolidays" class="pf-modal"> <div id="modalHolidays" class="pf-modal">
<div class="pf-modal-content"> <div class="pf-modal-content">
<div class="pf-modal-header"> <div class="pf-modal-header">
@@ -70,16 +161,20 @@ require __DIR__ . '/header.php';
<div class="pf-form-group"> <div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_person') ?></label> <label class="pf-label"><?= tr('fc_label_person') ?></label>
<select id="snapPerson" class="pf-input" required> <select id="snapPerson" class="pf-input" required>
<option value="2">Alex</option> <option value="" disabled selected>-- <?= tr('fc_choose_person') ?> --</option>
<option value="3">Laia</option> <?php foreach ($parents as $person): ?>
<option value="<?= htmlspecialchars($person['id']) ?>">
<?= htmlspecialchars($person['name']) ?>
</option>
<?php endforeach; ?>
</select> </select>
</div> </div>
<div class="pf-form-group"> <div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_leave_type') ?></label> <label class="pf-label"><?= tr('fc_label_leave_type') ?></label>
<select id="snapType" class="pf-input" required> <select id="snapType" class="pf-input" required>
<option value="CP">CP</option> <?php foreach ($allLeaveTypes as $type): ?>
<option value="JRA">JRA</option> <option value="<?= htmlspecialchars($type) ?>"><?= htmlspecialchars($type) ?></option>
<option value="JA">JA</option> <?php endforeach; ?>
</select> </select>
</div> </div>
</div> </div>
@@ -104,32 +199,142 @@ require __DIR__ . '/header.php';
</div> </div>
</div> </div>
</div> </div>
<div id="modalCalendarSettings" class="pf-modal">
<div class="pf-modal-content" style="max-width: 650px; width: 95%;">
<div class="pf-modal-header">
<h3 class="pf-modal-title">⚙️ <?= tr('fc_modal_settings_title') ?></h3>
<button type="button" class="pf-modal-close" onclick="closeCalendarSettings()">&times;</button>
</div>
<div style="display: flex; gap: 4px; background: var(--bg-page); padding: 4px; border-radius: 8px; margin: 1rem;">
<button type="button" id="tab-btn-foyer" class="bs-tab-btn active" style="flex: 1; padding: 8px 12px; font-weight: 600;" onclick="switchCalendarTab('foyer')">
🏡 <?= tr('fc_tab_foyer') ?>
</button>
<button type="button" id="tab-btn-membres" class="bs-tab-btn" style="flex: 1; padding: 8px 12px; font-weight: 600;" onclick="switchCalendarTab('membres')">
👥 <?= tr('fc_tab_members') ?>
</button>
</div>
<div class="pf-modal-body" style="max-height: 65vh; overflow-y: auto; padding-top: 0; padding-bottom: 1rem;">
<div id="cal-pane-foyer" class="cal-settings-pane">
<form id="formCalFoyer" onsubmit="submitCalFoyer(event)">
<div class="pf-form-group">
<label class="pf-label"><?= tr('fc_zone_label') ?></label>
<select id="setZoneScolaire" name="zone_scolaire" class="pf-input" required style="width: 100%;">
<option value="A"><?= tr('fc_zone_a') ?></option>
<option value="B"><?= tr('fc_zone_b') ?></option>
<option value="C"><?= tr('fc_zone_c') ?></option>
<option value="Autre"><?= tr('fc_zone_other') ?></option>
</select>
<small class="pf-muted-note"><?= tr('fc_zone_desc') ?></small>
</div>
<div class="pf-form-group" style="margin-top: 1.5rem;">
<label class="pf-label"><?= tr('fc_care_modes_label') ?></label>
<?php if (!empty($kids)): ?>
<div id="careModesContainer" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; min-height: 38px; padding: 8px; background: var(--bg-page); border-radius: var(--radius); border: 1px solid var(--border-light);"></div>
<div style="display: flex; gap: 8px;">
<input type="text" id="inputNewCareMode" class="pf-input" placeholder="<?= tr('fc_add_mode_placeholder') ?>" style="flex: 1;">
<button type="button" class="pf-btn btn-secondary" style="padding: 0 16px; border-radius: 6px;" onclick="addCareModeTag()"></button>
</div>
<small class="pf-muted-note"><?= tr('fc_care_modes_desc') ?></small>
<?php else: ?>
<div style="background: var(--bg-subtle); padding: 12px; border-radius: 8px; border: 1px dashed var(--border-main); text-align: center;">
<p style="margin: 0 0 10px 0; color: var(--text-muted); font-size: 0.9rem;">
<?= tr('fc_empty_kids_msg') ?>
</p>
<a href="/settings.php" class="pf-btn btn-secondary" style="text-decoration: none; display: inline-block; font-size: 0.85rem;">
⚙️ <?= tr('fc_link_settings') ?>
</a>
</div>
<?php endif; ?>
</div>
<?php if (!empty($kids)): ?>
<div style="margin-top: 1rem; display: flex; justify-content: flex-end;">
<button type="submit" class="pf-btn pf-btn-primary"><?= tr('save') ?></button>
</div>
<?php endif; ?>
</form>
<div class="pf-form-group" style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border-light);">
<h4 style="margin: 0 0 10px 0; color: var(--text-main);">🏖️ <?= tr('fc_leave_catalog_title') ?></h4>
<p style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 15px;">
<?= tr('fc_leave_catalog_desc') ?>
</p>
<div id="leaveTypesContainer" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px;"></div>
<div style="background: var(--bg-subtle); padding: 12px; border-radius: 8px; border: 1px solid var(--border-light);">
<h5 id="leaveTypeFormTitle" style="margin: 0 0 10px 0;">+ <?= tr('fc_add_leave_type') ?></h5>
<form id="formLeaveType" style="display: flex; flex-direction: column; gap: 10px;">
<input type="hidden" id="lt-mode" value="add">
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<div style="flex: 0 0 80px;">
<label class="pf-label"><?= tr('fc_col_code') ?> *</label>
<input type="text" id="lt-code" class="pf-input" maxlength="4" style="text-transform: uppercase;" required>
<small class="pf-muted-note" id="lt-code-note"><?= tr('fc_code_irreversible') ?></small>
</div>
<div style="flex: 1; min-width: 150px;">
<label class="pf-label"><?= tr('fc_col_label') ?> *</label>
<input type="text" id="lt-label" class="pf-input" placeholder="<?= tr('fc_leave_label_ph') ?>" required>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 5px;">
<button type="button" class="pf-btn btn-secondary" onclick="resetLeaveTypeForm()"><?= tr('btn_cancel') ?></button>
<button type="button" class="pf-btn pf-btn-primary" onclick="saveLeaveType()"><?= tr('btn_save') ?></button>
</div>
</form>
</div>
</div>
</div>
<div id="cal-pane-membres" class="cal-settings-pane" style="display: none;">
<div style="margin-bottom: 1.2rem;">
<label class="pf-label"><?= tr('fc_select_member') ?></label>
<select id="selectCalMember" class="pf-input" style="width: 100%; font-weight: 600;" onchange="loadMemberConfigView()">
</select>
</div>
<div id="memberConfigZone" style="background: var(--bg-page); padding: 14px; border-radius: 8px; border: 1px solid var(--border-light); min-height: 150px;">
</div>
</div>
</div>
</div>
</div> </div>
<section class="pf-section">
<div class="fc-month-calendar-wrapper"> <div class="fc-month-calendar-wrapper">
<div class="fc-month-header"> <div class="fc-month-header">
<div class="fc-month-nav-row"> <div class="fc-month-nav-row">
<button id="fc-prev-month" class="fc-nav-button"></button> <button id="fc-prev-month" class="fc-nav-button"></button>
<div id="fc-smart-date-selector" style="display:flex; align-items:center; justify-content:center; flex-grow:1; gap:6px;"> <div id="fc-smart-date-selector" style="display:flex; align-items:center; justify-content:center; flex-grow:1; gap:6px;">
<select id="fc-select-month" class="fc-smart-select"></select> <select id="fc-select-month" class="fc-smart-select"></select>
<select id="fc-select-year" class="fc-smart-select"></select> <select id="fc-select-year" class="fc-smart-select"></select>
<span id="fc-multi-month-suffix" style="display:none; font-size:1.3rem; font-weight:800; color:#0f172a; margin-left:4px;"></span> <span id="fc-multi-month-suffix" style="display:none; font-size:1.3rem; font-weight:800; color:#0f172a; margin-left:4px;"></span>
</div> </div>
<button id="fc-next-month" class="fc-nav-button"></button> <button id="fc-next-month" class="fc-nav-button"></button>
</div> </div>
<div class="fc-view-controls"> <div class="fc-view-controls">
<button id="fc-today-btn" class="fc-today-button" title="<?= tr('fc_today_title') ?>"><?= tr('fc_today_short') ?></button> <div class="fc-view-divider"></div> <button id="fc-today-btn" class="fc-today-button" title="<?= tr('fc_today_title') ?>"><?= tr('fc_today_short') ?></button>
<div class="fc-view-divider"></div>
<button class="fc-view-button fc-view-button--active" data-view="1month"><?= tr('fc_view_1m') ?></button> <button class="fc-view-button fc-view-button--active" data-view="1month"><?= tr('fc_view_1m') ?></button>
<button class="fc-view-button" data-view="2months"><?= tr('fc_view_2m') ?></button> <button class="fc-view-button" data-view="2months"><?= tr('fc_view_2m') ?></button>
<button class="fc-view-button" data-view="3months"><?= tr('fc_view_3m') ?></button> <button class="fc-view-button" data-view="3months"><?= tr('fc_view_3m') ?></button>
</div> </div>
</div> </div>
<div class="fc-calendar-container"> <div class="fc-calendar-container">
@@ -138,11 +343,9 @@ require __DIR__ . '/header.php';
</div> </div>
<div id="fc-month-balances" class="fc-month-balances"></div> <div id="fc-month-balances" class="fc-month-balances"></div>
</div> </div>
</section>
<section class="pf-section"> <section class="pf-section" style="margin-top: 1.5rem;">
<div class="fc-week-header"> <div class="fc-week-header">
<h2><?= tr('fc_weekly_planning') ?></h2> <h2><?= tr('fc_weekly_planning') ?></h2>
<div class="fc-week-nav-controls"> <div class="fc-week-nav-controls">
@@ -163,26 +366,45 @@ require __DIR__ . '/header.php';
<th rowspan="3" class="col-day"><?= tr('day_wed') ?></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_thu') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_fri') ?></th> <th rowspan="3" class="col-day"><?= tr('day_fri') ?></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_off_carole') ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_extra_off') ?></span></th> <?php foreach ($activeCareModes as $mode): ?>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_centre') ?></span></th> <th rowspan="3" class="col-total rotated-text"><span><?= htmlspecialchars($mode) ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_avis') ?></span></th> <?php endforeach; ?>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_pep_sick') ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_presence') ?></span></th> <?php foreach ($kids as $kid): ?>
<th colspan="6" class="col-alex header-group">ALEX</th> <th rowspan="3" class="col-total rotated-text"><span>Maladie <?= htmlspecialchars($kid['name']) ?></span></th>
<th colspan="6" class="col-laia header-group">LAIA</th> <?php endforeach; ?>
<?php foreach ($parents as $index => $parent):
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia'; // Couleurs alternées
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
$colspan = count($pLeaves) * 2;
?>
<?php if ($colspan > 0): ?>
<th colspan="<?= $colspan ?>" class="<?= $parentClass ?> header-group"><?= htmlspecialchars(strtoupper($parent['name'])) ?></th>
<?php endif; ?>
<?php endforeach; ?>
</tr> </tr>
<tr> <tr>
<th colspan="2" class="col-alex-sub">CP</th><th colspan="2" class="col-alex-sub">JRA</th><th colspan="2" class="col-alex-sub">JA</th> <?php foreach ($parents as $index => $parent):
<th colspan="2" class="col-laia-sub">CP</th><th colspan="2" class="col-laia-sub">JRA</th><th colspan="2" class="col-laia-sub">JA</th> $parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
?>
<?php foreach ($pLeaves as $type): ?>
<th colspan="2" class="<?= $parentClass ?>-sub"><?= htmlspecialchars($type) ?></th>
<?php endforeach; ?>
<?php endforeach; ?>
</tr> </tr>
<tr> <tr>
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th> <?php foreach ($parents as $index => $parent):
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th> $parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th> $pLeaves = $leaveMatrix[$parent['id']] ?? [];
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th> ?>
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th> <?php foreach ($pLeaves as $type): ?>
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th> <th class="<?= $parentClass ?>-sub <?= $parentClass ?>-av"><?= tr('fc_col_av') ?></th>
<th class="<?= $parentClass ?>-sub <?= $parentClass ?>-use"><?= tr('fc_col_use') ?></th>
<?php endforeach; ?>
<?php endforeach; ?>
</tr> </tr>
</thead> </thead>
<tbody id="planningBody"></tbody> <tbody id="planningBody"></tbody>
@@ -191,37 +413,52 @@ require __DIR__ . '/header.php';
</div> </div>
</section> </section>
<section class="pf-section pf-section--bottom-panels"> <section class="pf-section pf-section--bottom-panels" style="margin-top: 1.5rem;">
<div class="fc-bottom-grid">
<div class="pf-card">
<div class="pf-card-title fc-summary-header">
<span><?= tr('fc_summary_title') ?></span>
<div class="fc-summary-controls">
<select id="summType" class="fc-summ-select">
<option value="year"><?= tr('fc_summ_year') ?></option>
<option value="month"><?= tr('fc_summ_month') ?></option>
</select>
<select id="summValue" class="fc-summ-select"></select>
</div>
</div>
<div class="pf-card-body">
<div id="globalSummary" class="fc-summary-vertical"></div>
</div>
</div>
<div class="pf-card"> <div class="pf-card">
<h2 class="pf-card-title"><?= tr('fc_legend_title') ?></h2> <h2 class="pf-card-title"><?= tr('fc_legend_title') ?></h2>
<div class="pf-card-body"> <div class="pf-card-body">
<div class="pf-legend-grid"> <div class="pf-legend-grid">
<!-- Fériés & Vacances (Fixes) -->
<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-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> <div class="pf-legend-item"><div class="pf-legend-color fc-legend-public-holiday"></div><span><?= tr('leg_public_holiday') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-off-carole"></div><span><?= tr('leg_off_carole') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-extra-off-carole"></div><span><?= tr('leg_extra_off') ?></span></div> <!-- Intervenants (Dynamique) -->
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-centre"></div><span><?= tr('leg_centre') ?></span></div> <?php foreach ($helpers as $helper): ?>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-avis"></div><span><?= tr('leg_avis') ?></span></div> <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 fc-legend-pep-sick"></div><span><?= tr('leg_pep_sick') ?></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; ?>
<!-- Modes de garde (Dynamique avec icônes) -->
<?php foreach ($activeCareModes as $index => $mode):
$modeLower = strtolower($mode);
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">
<span><?= htmlspecialchars($mode) ?></span>
</div> </div>
<?php elseif ($modeLower === 'centre'): ?>
<div class="pf-legend-item">
<span style="font-size:1.1rem; line-height:1; margin-right:6px;">🏫</span>
<span><?= htmlspecialchars($mode) ?></span>
</div>
<?php else: ?>
<div class="pf-legend-item">
<div class="pf-legend-color" style="background: var(--primary);"></div>
<span><?= htmlspecialchars($mode) ?></span>
</div>
<?php endif;
endforeach; ?>
<!-- Enfants Malades (Dynamique avec couleur BDD) -->
<?php foreach ($kids as $kid):
// Fallback aligné exactement sur celui du JS (#e11d48)
$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>
<span>Maladie <?= htmlspecialchars($kid['name']) ?></span>
</div>
<?php endforeach; ?>
</div> </div>
</div> </div>
</div> </div>
@@ -229,33 +466,17 @@ require __DIR__ . '/header.php';
</div> </div>
<script> <script>
window.I18N = { window.FAMILY_CONFIG = {
...(window.I18N || {}), parents: <?= json_encode($parents) ?>,
'fc_menu_carole': "<?= tr('fc_menu_carole') ?>", kids: <?= json_encode($kids) ?>,
'btn_off': "<?= tr('btn_off') ?>", activeCareModes: <?= json_encode($activeCareModes) ?>,
'btn_extra': "<?= tr('btn_extra') ?>", i18n: {
'leg_centre': "<?= tr('leg_centre') ?>", settings_updated: <?= json_encode(tr('fc_settings_updated')) ?>,
'leg_avis': "<?= tr('leg_avis') ?>", empty_matrix: "Aucun compteur de congés défini pour le moment.",
'leg_pep_sick': "<?= tr('leg_pep_sick') ?>", btn_delete: <?= json_encode(tr('btn_delete') ?? 'Supprimer') ?>
'leg_off_carole': "<?= tr('leg_off_carole') ?>", }
'leg_extra_off': "<?= tr('leg_extra_off') ?>",
'leg_presence': "<?= tr('leg_presence') ?>",
'fc_menu_kids_leaves': "<?= tr('fc_menu_kids_leaves') ?>",
'fc_clear': "<?= tr('fc_clear') ?>",
'fc_unit_days': "<?= tr('fc_unit_days') ?>",
'vac_toussaint': "<?= tr('vac_toussaint') ?>",
'vac_noel': "<?= tr('vac_noel') ?>",
'vac_hiver': "<?= tr('vac_hiver') ?>",
'vac_printemps': "<?= tr('vac_printemps') ?>",
'vac_ascension': "<?= tr('vac_ascension') ?>",
'vac_ete': "<?= tr('vac_ete') ?>",
'leg_school_holidays': "<?= tr('leg_school_holidays') ?>",
'fc_school_year': "<?= tr('fc_school_year') ?>",
'fc_modal_holidays_title': "<?= tr('fc_modal_holidays_title') ?>",
'fc_alert_burn_days': "<?= tr('fc_alert_burn_days') ?>",
'fc_alert_burn_jra': "<?= tr('fc_alert_burn_jra') ?>",
'ANNIV': "<?= tr('ANNIV') ?>"
}; };
</script> </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'; ?> <?php require __DIR__ . '/footer.php'; ?>
+11 -1
View File
@@ -217,15 +217,25 @@ require __DIR__ . '/header.php';
</select> </select>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"><label class="form-label">Kilométrage</label><input class="form-control" id="vehicle-current-km" name="current_km" type="number" placeholder="85000"></div> <div class="form-group"><label class="form-label">Kilométrage</label><input class="form-control" id="vehicle-current-km" name="current_km" type="number" placeholder="85000"></div>
<div class="form-group"><label class="form-label">Couleur</label><input class="form-control" id="vehicle-color" name="color" placeholder="Blanc nacré"></div> <div class="form-group">
<label class="form-label">Conso. moy. (L/100km)</label>
<input class="form-control" id="vehicle-consumption" name="consumption" type="number" step="0.1" min="0" placeholder="ex: 6.5">
</div> </div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"><label class="form-label">Date d'achat</label><input class="form-control" id="vehicle-purchase-date" name="purchase_date" type="date"></div> <div class="form-group"><label class="form-label">Date d'achat</label><input class="form-control" id="vehicle-purchase-date" name="purchase_date" type="date"></div>
<div class="form-group"><label class="form-label">Prix d'achat (€)</label><input class="form-control" id="vehicle-purchase-price" name="purchase_price" type="number" step="0.01"></div> <div class="form-group"><label class="form-label">Prix d'achat (€)</label><input class="form-control" id="vehicle-purchase-price" name="purchase_price" type="number" step="0.01"></div>
</div> </div>
<div class="form-row">
<div class="form-group"><label class="form-label">VIN</label><input class="form-control" id="vehicle-vin" name="vin" placeholder="VF1..."></div> <div class="form-group"><label class="form-label">VIN</label><input class="form-control" id="vehicle-vin" name="vin" placeholder="VF1..."></div>
<div class="form-group"><label class="form-label">Couleur</label><input class="form-control" id="vehicle-color" name="color" placeholder="Blanc nacré"></div>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Photo</label> <label class="form-label">Photo</label>
<input class="form-control" id="vehicle-photo" name="photo" type="file" accept="image/*" onchange="previewPhoto('vehicle-photo','vehicle-photo-preview')"> <input class="form-control" id="vehicle-photo" name="photo" type="file" accept="image/*" onchange="previewPhoto('vehicle-photo','vehicle-photo-preview')">
+77
View File
@@ -0,0 +1,77 @@
<?php
require_once __DIR__ . '/includes/meta_db.php';
$db_host = getenv('DB_HOST') ?: '127.0.0.1';
$db_user = getenv('DB_USER') ?: 'househub';
$db_pass = getenv('DB_PASS') ?: 'househub_dev';
echo "<h1>📅 Génération du squelette du calendrier (pf_calendar_weeks)</h1>";
$monthsFr = [1=>'Janvier', 2=>'Février', 3=>'Mars', 4=>'Avril', 5=>'Mai', 6=>'Juin', 7=>'Juillet', 8=>'Août', 9=>'Septembre', 10=>'Octobre', 11=>'Novembre', 12=>'Décembre'];
try {
$stmt = $meta_pdo->query("SELECT db_name, name FROM families WHERE is_active = 1");
$families = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($families as $family) {
$dbName = $family['db_name'];
echo "<h3>Famille : {$family['name']} ($dbName)</h3><ul>";
try {
$pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
// On utilise REPLACE INTO pour écraser proprement si certaines semaines existent déjà
$sql = "REPLACE INTO pf_calendar_weeks
(year, week_iso_year, week_iso_number, week_label, month, month_name, week_start_date, mon_date, tue_date, wed_date, thu_date, fri_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$insertStmt = $pdo->prepare($sql);
// On génère de l'été 2023 jusqu'à fin 2030 !
$startDate = new DateTime('2023-08-28'); // Un lundi
$endDate = new DateTime('2030-12-31');
$count = 0;
while ($startDate <= $endDate) {
$mon = clone $startDate;
$tue = clone $startDate; $tue->modify('+1 day');
$wed = clone $startDate; $wed->modify('+2 days');
$thu = clone $startDate; $thu->modify('+3 days');
$fri = clone $startDate; $fri->modify('+4 days');
$year = (int)$mon->format('Y');
$month = (int)$mon->format('n');
$weekIsoYear = (int)$mon->format('o');
$weekIsoNumber = (int)$mon->format('W');
$weekLabel = "Semaine " . $weekIsoNumber;
$monthName = $monthsFr[$month];
$insertStmt->execute([
$year,
$weekIsoYear,
$weekIsoNumber,
$weekLabel,
$month,
$monthName,
$mon->format('Y-m-d'),
$mon->format('Y-m-d'),
$tue->format('Y-m-d'),
$wed->format('Y-m-d'),
$thu->format('Y-m-d'),
$fri->format('Y-m-d')
]);
$startDate->modify('+1 week');
$count++;
}
echo "<li>✅ $count semaines générées (de 2023 à 2030) !</li>";
} catch (\PDOException $e) {
echo "<li>❌ Erreur : " . $e->getMessage() . "</li>";
}
echo "</ul>";
}
echo "<h2>🎉 Terminé ! Tu peux supprimer ce fichier et rafraîchir ton calendrier.</h2>";
} catch (Exception $e) {
die("Erreur fatale Meta DB : " . $e->getMessage());
}
?>
-59
View File
@@ -1,59 +0,0 @@
<?php
/**
* HouseHub - CSS Auditor 🦙
* Génère un rapport complet de tous les styles (fichiers, balises, inline)
*/
header('Content-Type: text/plain; charset=utf-8');
$root = __DIR__;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root));
$report = "=== 🦙 RAPPORT CSS COMPLET HOUSEHUB ===\n";
$report .= "Généré le : " . date('Y-m-d H:i:s') . "\n\n";
foreach ($iterator as $file) {
if ($file->isDir()) continue;
$filePath = $file->getPathname();
$relativePath = str_replace($root, '', $filePath);
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
// 1. EXTRACTION DES FICHIERS .css
if ($ext === 'css') {
$report .= "/* 📄 FICHIER SOURCE : $relativePath */\n";
$report .= file_get_contents($filePath) . "\n";
$report .= str_repeat("-", 40) . "\n\n";
}
// 2. EXTRACTION DES BALISES <style> ET ATTRIBUTS style="" DANS LES .php
if ($ext === 'php') {
$content = file_get_contents($filePath);
$foundInFile = false;
// Extraction des blocs <style>
if (preg_match_all('/<style[^>]*>(.*?)<\/style>/is', $content, $matches)) {
if (!$foundInFile) { $report .= "/* 📄 FICHIER SOURCE : $relativePath */\n"; $foundInFile = true; }
foreach ($matches[1] as $idx => $styleBlock) {
$report .= "/* Bloc <style> #$idx */\n";
$report .= trim($styleBlock) . "\n";
}
}
// Extraction des styles inline style="..."
// On capture le tag pour donner du contexte au style inline
if (preg_match_all('/<([a-z0-9]+)[^>]*?\sstyle=["\']([^"]*?)["\'][^>]*>/i', $content, $matches)) {
if (!$foundInFile) { $report .= "/* 📄 FICHIER SOURCE : $relativePath */\n"; $foundInFile = true; }
foreach ($matches[0] as $idx => $fullTag) {
// On nettoie un peu pour ne garder que l'essentiel du tag et son style
$report .= "/* Style Inline #$idx */\n";
$report .= trim($fullTag) . "\n";
}
}
if ($foundInFile) {
$report .= str_repeat("-", 40) . "\n\n";
}
}
}
echo $report;
-72
View File
@@ -1,72 +0,0 @@
<?php
// generate_prompt.php - Script pour extraire le code de HouseHub
// À lancer via le navigateur (http://localhost:8082/generate_prompt.php) ou en ligne de commande.
$rootDir = __DIR__;
$outputFile = $rootDir . '/househub_source.md';
// Dossiers à ignorer (pour ne pas polluer le cerveau de l'IA avec des choses inutiles)
$ignoredDirs = ['.git', '.devtools', '.gitea', 'assets'];
// Extensions autorisées (on ne veut que du code, pas d'images)
$allowedExtensions = ['php', 'css', 'js', 'html', 'sh'];
$output = "# 🦙 Source Code HouseHub\n\n";
$output .= "> *Généré le " . date('Y-m-d H:i:s') . "*\n\n";
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($rootDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$fileCount = 0;
foreach ($iterator as $file) {
if ($file->isFile()) {
// Chemin relatif propre
$relativePath = str_replace($rootDir . DIRECTORY_SEPARATOR, '', $file->getPathname());
$relativePath = str_replace('\\', '/', $relativePath); // Uniformiser pour Windows
// Vérification des dossiers ignorés
$skip = false;
foreach ($ignoredDirs as $ignored) {
if (strpos($relativePath, $ignored) === 0) {
$skip = true;
break;
}
}
if ($skip) continue;
// Vérification de l'extension
$ext = strtolower($file->getExtension());
if (!in_array($ext, $allowedExtensions)) continue;
// On ignore ce script lui-même
if ($file->getFilename() === 'generate_prompt.php') continue;
// Lecture du contenu
$content = file_get_contents($file->getPathname());
// Détermination du langage pour le bloc Markdown
$lang = $ext === 'php' ? 'php' : ($ext === 'js' ? 'javascript' : $ext);
// Formatage pour l'IA
$output .= "### 📄 Fichier : `" . $relativePath . "`\n";
$output .= "```" . $lang . "\n";
$output .= $content . "\n";
$output .= "```\n\n";
$output .= "---\n\n";
$fileCount++;
}
}
file_put_contents($outputFile, $output);
echo "<div style='font-family:sans-serif; padding:20px; background:#f0fdf4; color:#16a34a; border-radius:8px; border:1px solid #bbf7d0;'>";
echo "<h2>✅ Succès !</h2>";
echo "<p>Le fichier <strong>househub_source.md</strong> a été généré à la racine de ton projet.</p>";
echo "<p><strong>$fileCount fichiers</strong> de code ont été fusionnés.</p>";
echo "<p><em>Tu peux maintenant donner ce fichier à ta Gem HouseHub !</em></p>";
echo "</div>";
?>
+519 -79
View File
@@ -8,51 +8,36 @@ require_once __DIR__ . '/includes/i18n.php';
if (session_status() === PHP_SESSION_NONE) { session_start(); } if (session_status() === PHP_SESSION_NONE) { session_start(); }
// --- 1. CONFIGURATION --- // --- 1. CONFIGURATION DE BASE ---
$year = (int)date('Y'); $year = (int)date('Y');
$pageTitle = tr('gift_page_title'); $pageTitle = tr('gift_page_title');
$activePage = "gift-list"; $activePage = "gift-list";
$mainClass = "pf-gift-list"; $mainClass = "pf-gift-list";
$pageCss = "/modules/gift-list/gift-list.css"; $pageCss = "/modules/gift-list/gift-list.css";
$children = ['Pol', 'Pep', 'Elna', 'Bru', 'Guim']; // --- 2. RÉCUPÉRATION DYNAMIQUE DES DONNÉES ---
$baseAdults = ['Laia', 'Laura', 'Avi Iaia']; // On inclut les enfants du foyer ET les proches enfants
$extraAdults = ['Pauline', 'Papy JC', 'Mamy Caro']; $stmt = $pdo->query("SELECT name FROM pf_people WHERE role IN ('enfant', 'proche_enfant') ORDER BY name ASC");
$children = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: ['Aucun enfant configuré'];
$adultsByChildForAnniv = [ // On inclut les adultes du foyer ET les proches adultes (On exclut nounou et les enfants)
'Pol' => array_merge($baseAdults, $extraAdults), $stmt = $pdo->query("SELECT name FROM pf_people WHERE role NOT IN ('enfant', 'proche_enfant', 'nounou') ORDER BY name ASC");
'Pep' => array_merge($baseAdults, $extraAdults), $allAdultsList = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: ['Aucun adulte configuré'];
'Elna' => $baseAdults,
'Bru' => $baseAdults,
'Guim' => $baseAdults,
];
$VIEWS = [ // On récupère toutes les occasions (avec l'icône)
'nadal' => ['TIO', 'NOEL', 'ROIS'], $stmt = $pdo->query("SELECT code, name, icon, month_date FROM pf_gift_occasions WHERE is_active = 1 ORDER BY sort_order ASC, month_date ASC, id ASC");
'anniversary' => ['ANNIV', 'SANT'], $activeOccasions = $stmt->fetchAll(PDO::FETCH_ASSOC);
];
$currentView = strtolower($_GET['view'] ?? ($_SESSION['gift_view'] ?? 'nadal')); if (empty($activeOccasions)) {
if (!isset($VIEWS[$currentView])) $currentView = 'nadal'; $activeOccasions = [['code' => 'DEMO', 'name' => 'Fête par défaut', 'icon' => '🎁', 'month_date' => null]];
$_SESSION['gift_view'] = $currentView; }
$allowedOccasions = $VIEWS[$currentView]; $allowedOccasionCodes = array_column($activeOccasions, 'code');
$allOccasionLabels = [ // --- 3. CHARGEMENT DES CADEAUX ---
'TIO' => tr('gift_occ_tio'), 'NOEL' => tr('gift_occ_noel'), 'ROIS' => tr('gift_occ_rois'), $inMarks = implode(',', array_fill(0, count($allowedOccasionCodes), '?'));
'ANNIV' => tr('gift_occ_anniv'), 'SANT' => tr('gift_occ_sant')
];
$occasionIcons = [
'TIO' => '/modules/gift-list/assets/img/tio.png', 'NOEL' => '/modules/gift-list/assets/img/santa.png',
'ROIS' => '/modules/gift-list/assets/img/reis.png', 'ANNIV' => '/modules/gift-list/assets/img/corona.png',
'SANT' => '/modules/gift-list/assets/img/sant.png'
];
// --- 2. DONNÉES ---
$inMarks = implode(',', array_fill(0, count($allowedOccasions), '?'));
// Tri: Par Fête, puis Enfant, puis Adulte
$sql = "SELECT * FROM pf_gifts WHERE year = ? AND occasion IN ($inMarks) ORDER BY occasion ASC, child_name ASC, adult_name ASC"; $sql = "SELECT * FROM pf_gifts WHERE year = ? AND occasion IN ($inMarks) ORDER BY occasion ASC, child_name ASC, adult_name ASC";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([$year], $allowedOccasions)); $stmt->execute(array_merge([$year], $allowedOccasionCodes));
$gifts = $stmt->fetchAll(PDO::FETCH_ASSOC); $gifts = $stmt->fetchAll(PDO::FETCH_ASSOC);
$data = []; $data = [];
@@ -62,12 +47,16 @@ foreach ($gifts as $g) {
$data[$g['occasion']][$g['child_name']]['gifts'][] = $g; $data[$g['occasion']][$g['child_name']]['gifts'][] = $g;
$data[$g['occasion']][$g['child_name']]['totals'][$g['adult_name']] = ($data[$g['occasion']][$g['child_name']]['totals'][$g['adult_name']] ?? 0) + $g['amount']; $data[$g['occasion']][$g['child_name']]['totals'][$g['adult_name']] = ($data[$g['occasion']][$g['child_name']]['totals'][$g['adult_name']] ?? 0) + $g['amount'];
$adultsInView[$g['adult_name']] = true; $adultsInView[$g['adult_name']] = true;
if (!empty($g['payer_name'])) {
$adultsInView[$g['payer_name']] = true;
}
} }
$allAdultsList = array_keys($adultsInView); sort($allAdultsList);
// --- 3. TRICOUNT --- // --- 4. TRICOUNT (Bilan et Remboursements) ---
$people = array_values(array_unique(array_merge($baseAdults, array_column($gifts, 'adult_name'), array_column($gifts, 'payer_name')))); $people = array_values(array_unique(array_merge($allAdultsList, array_keys($adultsInView))));
$people = array_filter($people); $people = array_filter($people);
sort($people);
$matrix = []; $matrix = [];
foreach ($people as $p1) { foreach ($people as $p2) $matrix[$p1][$p2] = 0.0; } foreach ($people as $p1) { foreach ($people as $p2) $matrix[$p1][$p2] = 0.0; }
@@ -94,18 +83,19 @@ for ($i = 0; $i < $countPeople; $i++) {
require __DIR__ . '/header.php'; require __DIR__ . '/header.php';
?> ?>
<div class="pf-container cl-view-<?= htmlspecialchars($currentView) ?>"> <div class="pf-container cl-view-dynamic">
<div class="cl-titlebar"> <div class="cl-titlebar">
<h1>🎁 <?= sprintf(tr('gift_main_title'), $year) ?></h1> <div style="display: flex; align-items: center; gap: 12px;">
<div class="cl-view-switch"> <h1 style="margin: 0;">🎁 <?= sprintf(tr('gift_main_title'), $year) ?></h1>
<a href="?view=nadal" class="cl-view-btn <?= $currentView === 'nadal' ? 'is-active' : '' ?>"><?= tr('gift_view_nadal') ?></a> <button id="btn-open-gift-settings" class="btn-settings-gear" title="<?= tr('gift_settings') ?>" style="font-size: 1.4rem; padding: 4px;">
<a href="?view=anniversary" class="cl-view-btn <?= $currentView === 'anniversary' ? 'is-active' : '' ?>"><?= tr('gift_view_anniv') ?></a> ⚙️
</button>
</div> </div>
</div> </div>
<div class="pf-filter-bar"> <div class="pf-filter-bar">
<span style="font-size:1.2rem;">🔍</span> <span class="pf-filter-icon">🔍</span>
<div class="pf-multi-select" id="ms-child"> <div class="pf-multi-select" id="ms-child">
<div class="pf-ms-trigger" onclick="toggleMS('ms-child-list', this)"> <div class="pf-ms-trigger" onclick="toggleMS('ms-child-list', this)">
@@ -139,16 +129,20 @@ require __DIR__ . '/header.php';
</div> </div>
<section class="pf-section"> <section class="pf-section">
<?php foreach ($allowedOccasions as $occCode): ?> <?php foreach ($activeOccasions as $occ):
$occCode = $occ['code'];
// Affichage Brut de la BDD pour le nom de l'occasion
$occName = $occ['name'];
$occIcon = $occ['icon'] ?: '🎁';
?>
<div class="js-occ-section"> <div class="js-occ-section">
<h2 class="cl-occasion-title"> <h2 class="cl-occasion-title">
<?php if(!empty($occasionIcons[$occCode])): ?><img src="<?= $occasionIcons[$occCode] ?>" class="cl-occasion-icon"><?php endif; ?> <span class="cl-occasion-icon"><?= htmlspecialchars($occIcon) ?></span>
<?= $allOccasionLabels[$occCode] ?> <?= htmlspecialchars($occName) ?>
</h2> </h2>
<?php foreach ($children as $child): <?php foreach ($children as $child):
$childData = $data[$occCode][$child] ?? ['gifts' => [], 'totals' => []]; $childData = $data[$occCode][$child] ?? ['gifts' => [], 'totals' => []];
$adultsForThisChild = ($currentView === 'anniversary' && in_array($child, ['Pol', 'Pep'])) ? array_merge($baseAdults, $extraAdults) : $baseAdults;
?> ?>
<div class="pf-child-section js-child" data-name="<?= htmlspecialchars($child) ?>"> <div class="pf-child-section js-child" data-name="<?= htmlspecialchars($child) ?>">
<div class="pf-child-header"> <div class="pf-child-header">
@@ -156,7 +150,7 @@ require __DIR__ . '/header.php';
<button class="pf-btn pf-btn-small btn-add-gift" <button class="pf-btn pf-btn-small btn-add-gift"
data-child="<?= htmlspecialchars($child) ?>" data-child="<?= htmlspecialchars($child) ?>"
data-occ="<?= htmlspecialchars($occCode) ?>" data-occ="<?= htmlspecialchars($occCode) ?>"
data-adults="<?= htmlspecialchars(json_encode(array_values($adultsForThisChild))) ?>"> data-adults="<?= htmlspecialchars(json_encode(array_values($allAdultsList))) ?>">
<?= tr('gift_add_gift') ?> <?= tr('gift_add_gift') ?>
</button> </button>
</div> </div>
@@ -168,9 +162,9 @@ require __DIR__ . '/header.php';
</div> </div>
<?php if (empty($childData['gifts'])): ?> <?php if (empty($childData['gifts'])): ?>
<p class="js-empty-state" style="color:var(--text-muted); font-size:0.9rem; font-style:italic; margin:0;"><?= tr('gift_empty_state_no_gifts') ?></p> <p class="js-empty-state gift-empty-state"><?= tr('gift_empty_state_no_gifts') ?></p>
<?php else: ?> <?php else: ?>
<p class="js-empty-state" style="color:var(--text-muted); font-size:0.9rem; font-style:italic; display:none; margin:0;"><?= tr('gift_empty_state_no_filter') ?></p> <p class="js-empty-state gift-empty-state pf-hidden"><?= tr('gift_empty_state_no_filter') ?></p>
<div class="pf-gift-feed"> <div class="pf-gift-feed">
<?php foreach ($childData['gifts'] as $g): ?> <?php foreach ($childData['gifts'] as $g): ?>
<div class="pf-gift-card-compact js-gift-card" data-adult="<?= htmlspecialchars($g['adult_name']) ?>"> <div class="pf-gift-card-compact js-gift-card" data-adult="<?= htmlspecialchars($g['adult_name']) ?>">
@@ -201,41 +195,42 @@ require __DIR__ . '/header.php';
<?php endforeach; ?> <?php endforeach; ?>
</section> </section>
<section class="pf-section pf-section--panel" style="border:1px solid var(--border-light); margin-top:40px;"> <section class="pf-section pf-section--panel gift-tricount-section">
<h2 style="margin-top:0; color:var(--text-main); font-size:1.3rem;">⚖️ <?= tr('gift_liquidations') ?? 'Bilan & Remboursements' ?></h2> <h2 class="gift-tricount-title">⚖️ <?= tr('gift_liquidations') ?></h2>
<?php if (empty($settlements)): ?> <?php if (empty($settlements)): ?>
<p style="color:var(--success); font-weight:700; margin-bottom:0;">✅ <?= tr('gift_no_debt') ?></p> <p class="gift-tricount-success">✅ <?= tr('gift_no_debt') ?></p>
<?php else: ?> <?php else: ?>
<ul class="pf-tricount-list"> <ul class="pf-tricount-list">
<?php foreach ($settlements as $s): ?> <?php foreach ($settlements as $s): ?>
<li class="pf-tricount-item"> <li class="pf-tricount-item">
<span><strong><?= htmlspecialchars($s['from']) ?></strong> <?= tr('gift_owes') ?> à <?= htmlspecialchars($s['to']) ?></span> <span><strong><?= htmlspecialchars($s['from']) ?></strong> <?= tr('gift_owes') ?> <?= tr('gift_to') ?> <?= htmlspecialchars($s['to']) ?></span>
<strong style="color:var(--danger);"><?= number_format($s['amount'], 2, ',', ' ') ?> €</strong> <strong class="gift-tricount-debt-amount"><?= number_format($s['amount'], 2, ',', ' ') ?> €</strong>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
<?php endif; ?> <?php endif; ?>
<details style="margin-top:20px; padding:12px; border-radius:8px;"> <details class="gift-matrix-details">
<summary style="cursor:pointer; font-weight:600; color:var(--text-muted); outline:none;"><?= tr('gift_view_matrix') ?></summary> <summary class="gift-matrix-summary"><?= tr('gift_view_matrix') ?></summary>
<div style="overflow-x:auto; margin-top:15px;"> <div class="gift-matrix-wrapper">
<table class="pf-table pf-table--compact cl-debt-matrix"> <table class="pf-table pf-table--compact cl-debt-matrix">
<thead> <thead>
<tr> <tr>
<th style="position:sticky; left:0; background:#f8fafc; z-index:2; border-right:2px solid #e2e8f0;"><?= tr('gift_debtor') ?> \ <?= tr('gift_creditor') ?></th> <th class="sticky-col bg-header"><?= tr('gift_debtor') ?> \ <?= tr('gift_creditor') ?></th>
<?php foreach ($people as $p): ?><th><?= htmlspecialchars($p) ?></th><?php endforeach; ?> <?php foreach ($people as $p): ?><th><?= htmlspecialchars($p) ?></th><?php endforeach; ?>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($people as $debtor): ?> <?php foreach ($people as $debtor): ?>
<tr> <tr>
<th style="position:sticky; left:0; background:white; z-index:2; border-right:2px solid #e2e8f0;"><?= htmlspecialchars($debtor) ?></th> <th class="sticky-col bg-body"><?= htmlspecialchars($debtor) ?></th>
<?php foreach ($people as $creditor): <?php foreach ($people as $creditor):
$val = $matrix[$debtor][$creditor] ?? 0; $val = $matrix[$debtor][$creditor] ?? 0;
$display = ($debtor === $creditor) || $val == 0 ? '—' : number_format($val, 2, ',', ' ') . ' €'; $display = ($debtor === $creditor) || $val == 0 ? '—' : number_format($val, 2, ',', ' ') . ' €';
$cellClass = $val > 0 ? 'gift-cell-danger' : 'gift-cell-muted';
?> ?>
<td style="<?= $val > 0 ? 'color:var(--danger); font-weight:700; background:#fef2f2;' : 'color:var(--text-muted);' ?>"><?= $display ?></td> <td class="<?= $cellClass ?>"><?= $display ?></td>
<?php endforeach; ?> <?php endforeach; ?>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
@@ -246,11 +241,12 @@ require __DIR__ . '/header.php';
</section> </section>
</div> </div>
<!-- 1. Modale CRUD Cadeau -->
<div id="pf-gift-modal" class="pf-modal"> <div id="pf-gift-modal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 500px; width: 95%;"> <div class="pf-modal-content gift-modal-custom-content">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;"> <div class="gift-modal-custom-header">
<h3 id="modalTitle" class="pf-modal-title" style="margin:0; border:none; padding:0;">Cadeau</h3> <h3 id="modalTitle" class="pf-modal-title gift-modal-custom-title">Cadeau</h3>
<button type="button" class="btn-modal-close" style="background:none; border:none; font-size:1.8rem; cursor:pointer; color:var(--text-muted); line-height:1;">&times;</button> <button type="button" class="btn-modal-close gift-modal-close-btn">&times;</button>
</div> </div>
<form method="post" action="/modules/gift-list/save-gift.php" id="giftForm"> <form method="post" action="/modules/gift-list/save-gift.php" id="giftForm">
@@ -264,9 +260,9 @@ require __DIR__ . '/header.php';
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_payer') ?></label><select name="payer_name" id="modalPayer" class="pf-input" required></select></div> <div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_payer') ?></label><select name="payer_name" id="modalPayer" class="pf-input" required></select></div>
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_gift_name') ?></label><input type="text" name="gift_description" id="modalDesc" class="pf-input" required></div> <div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_gift_name') ?></label><input type="text" name="gift_description" id="modalDesc" class="pf-input" required></div>
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_price') ?></label><input type="number" step="0.01" name="amount" id="modalAmount" class="pf-input"></div> <div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_price') ?></label><input type="number" step="0.01" name="amount" id="modalAmount" class="pf-input"></div>
<div class="pf-form-group" style="margin-bottom:25px;"><label class="pf-label"><?= tr('gift_modal_link') ?></label><input type="url" name="product_link" id="modalLink" class="pf-input"></div> <div class="pf-form-group gift-form-group-spaced"><label class="pf-label"><?= tr('gift_modal_link') ?></label><input type="url" name="product_link" id="modalLink" class="pf-input"></div>
<div class="modal-footer" style="padding-top:15px; display:flex; justify-content:flex-end; gap:10px;"> <div class="modal-footer gift-modal-custom-footer">
<button type="button" class="pf-btn btn-secondary btn-modal-close"><?= tr('btn_cancel') ?></button> <button type="button" class="pf-btn btn-secondary btn-modal-close"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button> <button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</div> </div>
@@ -274,6 +270,91 @@ require __DIR__ . '/header.php';
</div> </div>
</div> </div>
<!-- 2. Modale Paramètres (Occasions & Proches) -->
<div id="modal-gift-settings" class="pf-modal">
<div class="pf-modal-content" style="max-width: 600px; width: 90%;">
<div class="pf-modal-header" style="display: flex; justify-content: space-between; align-items: center;">
<h3 class="pf-modal-title" style="margin: 0;">⚙️ <?= tr('gift_settings') ?></h3>
<button type="button" class="pf-modal-close" style="background:none; border:none; font-size:1.5rem; cursor:pointer;" onclick="closeGiftSettings()">&times;</button>
</div>
<div style="display: flex; gap: 4px; background: var(--bg-page); padding: 4px; border-radius: 8px; margin: 1rem 0;">
<button type="button" id="tab-btn-occasions" class="bs-tab-btn active" style="flex: 1; padding: 8px 12px; font-weight: 600; border-radius: 6px; border: none; cursor: pointer; transition: 0.2s;" onclick="switchGLTab('occasions')"><?= tr('gift_tab_occasions') ?></button>
<button type="button" id="tab-btn-proches" class="bs-tab-btn" style="flex: 1; padding: 8px 12px; font-weight: 600; border-radius: 6px; border: none; cursor: pointer; transition: 0.2s; background: transparent;" onclick="switchGLTab('proches')"><?= tr('gift_tab_proches') ?></button>
</div>
<div class="pf-modal-body" style="min-height: 450px; max-height: 60vh; overflow-y: auto; padding: 0 1rem 1rem 1rem; display: flex; flex-direction: column;">
<div id="gl-pane-occasions" class="gl-pane active" style="display: block; animation: fadeIn 0.3s ease;">
<div id="occasions-list" style="margin-bottom: 20px;">⏳ <?= tr('loading') ?></div>
<hr style="border:0; border-top: 1px solid var(--border-light); margin: 20px 0;">
<h5 style="margin: 0 0 15px 0; font-size: 1rem; color: var(--text-main);"><?= tr('gift_settings_add_occ') ?></h5>
<form id="form-add-occasion" style="display: flex; flex-direction: column; gap: 16px;">
<div>
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_settings_icon') ?></label>
<input type="hidden" name="icon" id="add-occ-icon" value="🎁">
<div class="emoji-picker" style="display: flex; gap: 8px; flex-wrap: wrap;">
<div class="emoji-option selected" onclick="selectEmoji(this, 'add-occ-icon', '🎁')">🎁</div>
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎄')">🎄</div>
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎂')">🎂</div>
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎉')">🎉</div>
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎓')">🎓</div>
</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<div style="flex: 1; min-width: 150px;">
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_modal_gift_name') ?> *</label>
<input type="text" name="name" id="add-occ-name" class="pf-input" style="width: 100%;" required>
</div>
<div style="width: 110px;">
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_settings_date') ?></label>
<input type="text" name="month_date" id="add-occ-date" class="pf-input" style="width: 100%;" placeholder="MM-DD">
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px;">
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('form-add-occasion').reset()"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn pf-btn-primary"><?= tr('btn_add') ?></button>
</div>
</form>
</div>
<div id="gl-pane-proches" class="gl-pane" style="display: none; animation: fadeIn 0.3s ease;">
<div id="proches-list" style="margin-bottom: 20px;">⏳ <?= tr('loading') ?></div>
<hr style="border:0; border-top: 1px solid var(--border-light); margin: auto 0 20px 0;">
<h5 style="margin: 0 0 15px 0; font-size: 1rem; color: var(--text-main);"><?= tr('gift_settings_add_proche') ?></h5>
<form id="form-add-proche" style="display: flex; flex-direction: column; gap: 16px;">
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<div style="flex: 1; min-width: 150px;">
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_ph_proche_name') ?> *</label>
<input type="text" name="name" id="add-proche-name" class="pf-input" style="width: 100%;" required>
</div>
<div style="flex: 1; min-width: 150px;">
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_role_label') ?> *</label>
<select name="role" id="add-proche-role" class="pf-input" style="width: 100%;" required>
<option value="proche_adulte"><?= tr('gift_role_proche_adulte') ?></option>
<option value="proche_enfant"><?= tr('gift_role_proche_enfant') ?></option>
</select>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px;">
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('form-add-proche').reset()"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn pf-btn-primary"><?= tr('btn_add') ?></button>
</div>
</form>
</div>
</div>
</div>
</div>
<script> <script>
// ========================================== // ==========================================
// 1. GESTION DU COMPOSANT MULTI-SELECT VANILLA // 1. GESTION DU COMPOSANT MULTI-SELECT VANILLA
@@ -381,14 +462,12 @@ function applyGiftFilters() {
} }
// ========================================== // ==========================================
// 3. LOGIQUE DE LA MODALE & DELEGATION JS // 2. GESTION DE LA MODALE CADEAU (AJOUT/ÉDITION)
// ========================================== // ==========================================
const modal = document.getElementById('pf-gift-modal'); const modal = document.getElementById('pf-gift-modal');
const adultSelect = document.getElementById('modalAdult'); const adultSelect = document.getElementById('modalAdult');
const payerSelect = document.getElementById('modalPayer'); const payerSelect = document.getElementById('modalPayer');
// Synchronisation automatique : Adulte -> Payeur
if (adultSelect && payerSelect) { if (adultSelect && payerSelect) {
adultSelect.addEventListener('change', function() { adultSelect.addEventListener('change', function() {
let exists = false; let exists = false;
@@ -409,10 +488,7 @@ function populateSelects(adults) {
}); });
} }
// L'écouteur d'événements global pour tous les boutons !
document.body.addEventListener('click', async function(e) { document.body.addEventListener('click', async function(e) {
// --- 1. BOUTON FERMER MODALE ---
if (e.target.closest('.btn-modal-close')) { if (e.target.closest('.btn-modal-close')) {
if (modal) { if (modal) {
modal.classList.remove('open'); modal.classList.remove('open');
@@ -421,7 +497,6 @@ document.body.addEventListener('click', async function(e) {
return; return;
} }
// --- 2. BOUTON AJOUTER (+) ---
const btnAdd = e.target.closest('.btn-add-gift'); const btnAdd = e.target.closest('.btn-add-gift');
if (btnAdd) { if (btnAdd) {
const childName = btnAdd.dataset.child; const childName = btnAdd.dataset.child;
@@ -453,7 +528,6 @@ document.body.addEventListener('click', async function(e) {
return; return;
} }
// --- 3. BOUTON MODIFIER (CRAYON) ---
const btnEdit = e.target.closest('.btn-edit-gift'); const btnEdit = e.target.closest('.btn-edit-gift');
if (btnEdit) { if (btnEdit) {
const data = JSON.parse(btnEdit.dataset.gift || '{}'); const data = JSON.parse(btnEdit.dataset.gift || '{}');
@@ -485,7 +559,6 @@ document.body.addEventListener('click', async function(e) {
return; return;
} }
// --- 4. BOUTON SUPPRIMER (POUBELLE) ---
const btnDel = e.target.closest('.btn-delete-gift'); const btnDel = e.target.closest('.btn-delete-gift');
if (btnDel) { if (btnDel) {
const giftId = btnDel.dataset.id; const giftId = btnDel.dataset.id;
@@ -506,10 +579,6 @@ document.body.addEventListener('click', async function(e) {
} }
}); });
// ==========================================
// 4. SOUMISSION AJAX DU FORMULAIRE
// ==========================================
const giftForm = document.getElementById('giftForm'); const giftForm = document.getElementById('giftForm');
if (giftForm) { if (giftForm) {
giftForm.addEventListener('submit', async (e) => { giftForm.addEventListener('submit', async (e) => {
@@ -529,6 +598,377 @@ if (giftForm) {
} }
}); });
} }
// ==========================================
// 3. GESTION DES PARAMÈTRES (MODALE FÊTES & PROCHES)
// ==========================================
function closeGiftSettings() {
document.getElementById('modal-gift-settings').classList.remove('open');
document.body.classList.remove('no-scroll');
}
document.getElementById('btn-open-gift-settings')?.addEventListener('click', async (e) => {
e.preventDefault();
document.getElementById('modal-gift-settings').classList.add('open');
document.body.classList.add('no-scroll');
await loadSettingsData();
});
document.querySelectorAll('.btn-close-settings').forEach(btn => btn.addEventListener('click', closeGiftSettings));
function switchGLTab(tabId) {
// Gestion du contenu (Afficher/Cacher)
document.getElementById('gl-pane-occasions').style.display = (tabId === 'occasions') ? 'block' : 'none';
document.getElementById('gl-pane-proches').style.display = (tabId === 'proches') ? 'block' : 'none';
// Gestion du style des boutons d'onglet
const btnOcc = document.getElementById('tab-btn-occasions');
const btnProche = document.getElementById('tab-btn-proches');
if (tabId === 'occasions') {
btnOcc.style.background = 'var(--bg-panel)';
btnOcc.style.color = 'var(--primary)';
btnOcc.style.boxShadow = 'var(--shadow-sm)';
btnProche.style.background = 'transparent';
btnProche.style.color = 'var(--text-muted)';
btnProche.style.boxShadow = 'none';
} else {
btnProche.style.background = 'var(--bg-panel)';
btnProche.style.color = 'var(--primary)';
btnProche.style.boxShadow = 'var(--shadow-sm)';
btnOcc.style.background = 'transparent';
btnOcc.style.color = 'var(--text-muted)';
btnOcc.style.boxShadow = 'none';
}
}
let hasSettingsChanged = false;
async function loadSettingsData() {
try {
const res = await pachaFetch('/modules/gift-list/api-settings.php?action=get_all');
if (!res.success) throw new Error(res.error);
renderOccasions(res.data.occasions);
renderProches(res.data.proches);
} catch (e) {
document.getElementById('occasions-list').innerHTML = `<div class="pf-alert pf-alert--error">Erreur : ${e.message}</div>`;
document.getElementById('proches-list').innerHTML = `<div class="pf-alert pf-alert--error">Erreur : ${e.message}</div>`;
}
}
function renderOccasions(occasions) {
const container = document.getElementById('occasions-list');
if (!occasions.length) { container.innerHTML = `<em class="gift-empty-state">Vide</em>`; return; }
let html = '';
occasions.forEach(occ => {
const isChecked = occ.is_active == 1 ? 'checked' : '';
// ⚠️ Remarque l'ajout de id="row-occ-${occ.id}" ici :
html += `
<div class="gl-list-item" id="row-occ-${occ.id}">
<div>
<span style="font-size:1.2rem; margin-right:8px;">${occ.icon || '🎁'}</span>
<strong>${occ.name}</strong>
<small style="color:var(--text-muted); margin-left:8px;">${occ.month_date || ''}</small>
</div>
<div class="pf-flex-gap-8 align-center">
<label style="display:flex; align-items:center; cursor:pointer;">
<input type="checkbox" class="pf-checkbox-lg" ${isChecked} onchange="toggleOccasion(${occ.id}, this.checked)">
</label>
<button type="button" class="btn-icon-action edit" onclick="transformRowToEditOccasion('row-occ-${occ.id}', ${occ.id}, '${occ.name.replace(/'/g, "\\'")}', '${occ.icon}', '${occ.month_date || ''}')" title="${tr('btn_edit')}">✏️</button>
<button type="button" class="btn-icon-action delete" onclick="deleteOccasion(${occ.id})" title="${tr('btn_delete')}">🗑️</button>
</div>
</div>
`;
});
container.innerHTML = html;
}
function renderProches(proches) {
const container = document.getElementById('proches-list');
if (!proches.length) { container.innerHTML = `<em class="gift-empty-state">Aucun proche configuré.</em>`; return; }
let html = '';
proches.forEach(p => {
const roleLabel = p.role === 'proche_adulte' ? (tr('gift_role_proche_adulte') || 'Adulte') : (tr('gift_role_proche_enfant') || 'Enfant');
html += `
<div class="gl-list-item" id="row-proche-${p.id}">
<div><strong>${p.name}</strong> <small style="color:var(--text-muted);">— ${roleLabel}</small></div>
<div class="pf-flex-gap-8 align-center">
<button type="button" class="btn-icon-action edit" onclick="transformRowToEditProche('row-proche-${p.id}', ${p.id}, '${p.name.replace(/'/g, "\\'")}', '${p.role}')" title="${tr('btn_edit')}">✏️</button>
<button type="button" class="btn-icon-action delete" onclick="deleteProche(${p.id})" title="${tr('btn_delete')}">🗑️</button>
</div>
</div>
`;
});
container.innerHTML = html;
}
// ==========================================
// CRUD : OCCASIONS
// ==========================================
// 1. Ajouter une occasion
document.getElementById('form-add-occasion')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
hasSettingsChanged = true;
const fd = new FormData(e.target);
fd.append('action', 'save_occasion');
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
// On vide le formulaire
e.target.reset();
// On remet l'émoji par défaut visuellement
document.querySelectorAll('#form-add-occasion .emoji-option').forEach(el => el.classList.remove('selected'));
document.querySelector('#form-add-occasion .emoji-option').classList.add('selected');
document.getElementById('add-occ-icon').value = '🎁';
showToast("Fête ajoutée avec succès !", "success");
loadSettingsData(); // Recharge la liste
} catch (err) {
showToast("Erreur lors de l'ajout", "error");
console.error(err);
} finally {
btn.disabled = false;
}
});
// 2. Sauvegarder la modification (In-place)
async function saveInlineOccasion(id) {
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'save_occasion');
fd.append('id', id);
fd.append('icon', document.getElementById(`inline-occ-icon-${id}`).value);
fd.append('name', document.getElementById(`inline-occ-name-${id}`).value);
fd.append('month_date', document.getElementById(`inline-occ-date-${id}`).value);
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
showToast("Fête modifiée avec succès !", "success");
loadSettingsData();
} catch (err) {
showToast("Erreur lors de la modification", "error");
console.error(err);
}
}
// 3. Supprimer une occasion
async function deleteOccasion(id) {
if (!confirm(tr('confirm_delete') || 'Supprimer définitivement ?')) return;
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'delete_occasion');
fd.append('id', id);
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
showToast("Fête supprimée.", "success");
loadSettingsData();
} catch (err) {
showToast("Erreur lors de la suppression", "error");
}
}
// 4. Activer / Désactiver (Checkbox)
async function toggleOccasion(id, state) {
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'toggle_occasion');
fd.append('id', id);
fd.append('state', state ? 1 : 0);
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
showToast(state ? "Fête activée" : "Fête masquée", "success");
} catch (err) {
showToast("Erreur de mise à jour", "error");
}
}
// ==========================================
// CRUD : PROCHES
// ==========================================
// 1. Ajouter un proche
document.getElementById('form-add-proche')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
hasSettingsChanged = true;
const fd = new FormData(e.target);
fd.append('action', 'save_proche');
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
e.target.reset();
showToast("Proche ajouté avec succès !", "success");
loadSettingsData();
} catch (err) {
showToast("Erreur lors de l'ajout", "error");
} finally {
btn.disabled = false;
}
});
// 2. Sauvegarder la modification (In-place)
async function saveInlineProche(id) {
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'save_proche');
fd.append('id', id);
fd.append('name', document.getElementById(`inline-proche-name-${id}`).value);
fd.append('role', document.getElementById(`inline-proche-role-${id}`).value);
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
showToast("Proche mis à jour !", "success");
loadSettingsData();
} catch (err) {
showToast("Erreur lors de la mise à jour", "error");
}
}
// 3. Supprimer un proche
async function deleteProche(id) {
if (!confirm(tr('confirm_delete') || 'Supprimer ce proche ?')) return;
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'delete_proche');
fd.append('id', id);
try {
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
showToast("Proche retiré de la liste.", "success");
loadSettingsData();
} catch (err) {
showToast("Erreur lors de la suppression", "error");
}
}
// Rechargement intelligent lors de la fermeture si modifications
document.getElementById('modal-gift-settings').addEventListener('click', (e) => {
if (e.target.id === 'modal-gift-settings' || e.target.classList.contains('btn-close-settings')) {
if (hasSettingsChanged) {
showToast(tr('gift_settings_saved') || 'Paramètres mis à jour !', 'success');
setTimeout(() => window.location.reload(), 1000);
} else {
closeGiftSettings();
}
}
});
// Fonction pour gérer la sélection dans la grille d'émojis (formulaire d'ajout)
function selectEmoji(element, targetInputId, emoji) {
document.getElementById(targetInputId).value = emoji;
// Mise à jour visuelle de la sélection
const siblings = element.parentNode.querySelectorAll('.emoji-option');
siblings.forEach(el => el.classList.remove('selected'));
element.classList.add('selected');
}
// ==========================================
// LOGIQUE D'ÉDITION IN-PLACE (OCCASIONS)
// ==========================================
// On crée une mémoire pour stocker le HTML d'origine de chaque ligne
const rowStates = {};
function transformRowToEditOccasion(rowId, id, name, icon, date) {
const row = document.getElementById(rowId);
// On sauvegarde l'état proprement en JS (fini les bugs d'affichage !)
rowStates[rowId] = row.innerHTML;
row.innerHTML = `
<div style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; background: var(--bg-subtle); padding: 8px; border-radius: 8px; border: 1px solid var(--border-light);">
<select id="inline-occ-icon-${id}" class="pf-input" style="width: 60px; padding: 6px; margin: 0;">
<option value="🎁" ${icon==='🎁'?'selected':''}>🎁</option>
<option value="🎄" ${icon==='🎄'?'selected':''}>🎄</option>
<option value="🎂" ${icon==='🎂'?'selected':''}>🎂</option>
<option value="🎉" ${icon==='🎉'?'selected':''}>🎉</option>
<option value="🎓" ${icon==='🎓'?'selected':''}>🎓</option>
</select>
<input type="text" id="inline-occ-name-${id}" value="${name}" class="pf-input" style="flex: 1; min-width: 100px; padding: 6px; margin: 0;">
<input type="text" id="inline-occ-date-${id}" value="${date}" class="pf-input" style="width: 70px; padding: 6px; margin: 0;" placeholder="MM-JJ">
<div style="display: flex; gap: 4px; margin-left: auto;">
<button type="button" onclick="saveInlineOccasion(${id})" class="pf-btn pf-btn-primary" style="padding: 6px 10px;">💾</button>
<button type="button" onclick="cancelInlineEdit('${rowId}')" class="pf-btn btn-secondary" style="padding: 6px 10px;">❌</button>
</div>
</div>
`;
}
async function saveInlineOccasion(id) {
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'save_occasion');
fd.append('id', id);
fd.append('icon', document.getElementById(`inline-occ-icon-${id}`).value);
fd.append('name', document.getElementById(`inline-occ-name-${id}`).value);
fd.append('month_date', document.getElementById(`inline-occ-date-${id}`).value);
// On met à jour en BDD puis on recharge la liste !
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
loadSettingsData();
}
// ==========================================
// LOGIQUE D'ÉDITION IN-PLACE (PROCHES)
// ==========================================
function transformRowToEditProche(rowId, id, name, role) {
const row = document.getElementById(rowId);
rowStates[rowId] = row.innerHTML;
const roleAdulte = window.I18N ? window.I18N['gift_role_proche_adulte'] : 'Adulte';
const roleEnfant = window.I18N ? window.I18N['gift_role_proche_enfant'] : 'Enfant';
row.innerHTML = `
<div style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; background: var(--bg-subtle); padding: 8px; border-radius: 8px; border: 1px solid var(--border-light);">
<input type="text" id="inline-proche-name-${id}" value="${name}" class="pf-input" style="flex: 1; min-width: 100px; padding: 6px; margin: 0;">
<select id="inline-proche-role-${id}" class="pf-input" style="width: 130px; padding: 6px; margin: 0;">
<option value="proche_adulte" ${role==='proche_adulte'?'selected':''}>${roleAdulte}</option>
<option value="proche_enfant" ${role==='proche_enfant'?'selected':''}>${roleEnfant}</option>
</select>
<div style="display: flex; gap: 4px; margin-left: auto;">
<button type="button" onclick="saveInlineProche(${id})" class="pf-btn pf-btn-primary" style="padding: 6px 10px;">💾</button>
<button type="button" onclick="cancelInlineEdit('${rowId}')" class="pf-btn btn-secondary" style="padding: 6px 10px;">❌</button>
</div>
</div>
`;
}
async function saveInlineProche(id) {
hasSettingsChanged = true;
const fd = new FormData();
fd.append('action', 'save_proche');
fd.append('id', id);
fd.append('name', document.getElementById(`inline-proche-name-${id}`).value);
fd.append('role', document.getElementById(`inline-proche-role-${id}`).value);
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
loadSettingsData();
}
// Fonction pour restaurer la ligne (sans casser le HTML)
function cancelInlineEdit(rowId) {
document.getElementById(rowId).innerHTML = rowStates[rowId];
}
</script> </script>
<?php require __DIR__ . '/footer.php'; ?> <?php require __DIR__ . '/footer.php'; ?>
+333 -64
View File
@@ -307,7 +307,6 @@ body.pf-home .pf-mobile-close-btn {
.pf-muted-note { .pf-muted-note {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
margin-bottom: 20px;
} }
.pf-muted-note.pf-muted-note--tight { .pf-muted-note.pf-muted-note--tight {
margin-bottom: 8px; margin-bottom: 8px;
@@ -359,6 +358,58 @@ body.pf-home .pf-mobile-close-btn {
gap: 8px; gap: 8px;
} }
/* --- SÉLECTEUR DE COULEUR THÉMATIQUE (SETTINGS) --- */
.pf-color-picker-group {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.pf-input-color {
/* On réduit la taille pour le rendre plus subtil */
width: 36px;
height: 36px;
/* On enlève les marges/paddings par défaut */
padding: 0;
cursor: pointer;
/* On le rend complètement rond, c'est plus moderne */
border-radius: 50%;
border: 2px solid #e2e8f0;
/* On cache le fond par défaut du navigateur */
background: transparent;
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
transition: all 0.2s ease;
/* Optionnel : masquer la bordure interne sur Chrome/Safari */
-webkit-appearance: none;
}
/* On s'assure que le carré de couleur interne (Webkit) remplit tout et est rond */
.pf-input-color::-webkit-color-swatch-wrapper {
padding: 0;
}
.pf-input-color::-webkit-color-swatch {
border: none;
border-radius: 50%;
}
/* Même chose pour Firefox */
.pf-input-color::-moz-color-swatch {
border: none;
border-radius: 50%;
}
.pf-input-color:hover {
border-color: #94a3b8;
transform: scale(1.05);
}
.pf-color-picker-group .pf-muted-note {
font-size: 0.85rem;
color: var(--text-muted, #64748b);
line-height: 1.4;
}
/* Tuile module (checkbox) — état coché via :has(), compatible thème */ /* Tuile module (checkbox) — état coché via :has(), compatible thème */
.pf-module-tile { .pf-module-tile {
display: flex; display: flex;
@@ -973,16 +1024,18 @@ p {
--bg-page: #0d1117; --bg-page: #0d1117;
--bg-panel: #161b22; --bg-panel: #161b22;
--border-light: #30363d; --border-light: #30363d;
--shadow: 0 4px 6px -1px rgba(0,0,0,.4), 0 2px 4px -2px rgba(0,0,0,.3); --shadow:
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,.5), 0 4px 6px -4px rgba(0,0,0,.4); 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--bg-subtle: rgba(255,255,255,.06); --shadow-lg:
--bg-soft: rgba(59,130,246,.15); 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--bg-danger-soft: rgba(239,68,68,.1); --bg-subtle: rgba(255, 255, 255, 0.06);
--bg-soft: rgba(59, 130, 246, 0.15);
--bg-danger-soft: rgba(239, 68, 68, 0.1);
--bg-overlay: rgba(0, 0, 0, 0.55); --bg-overlay: rgba(0, 0, 0, 0.55);
--text-danger-soft: #f87171; --text-danger-soft: #f87171;
--border-strong: #484f58; --border-strong: #484f58;
--border-danger-soft: rgba(239,68,68,.3); --border-danger-soft: rgba(239, 68, 68, 0.3);
--focus-ring: rgba(59,130,246,.25); --focus-ring: rgba(59, 130, 246, 0.25);
--alert-success-bg: rgba(34, 197, 94, 0.12); --alert-success-bg: rgba(34, 197, 94, 0.12);
--alert-success-border: rgba(34, 197, 94, 0.45); --alert-success-border: rgba(34, 197, 94, 0.45);
--alert-success-text: #86efac; --alert-success-text: #86efac;
@@ -993,67 +1046,283 @@ p {
--badge-admin-bg: rgba(59, 130, 246, 0.2); --badge-admin-bg: rgba(59, 130, 246, 0.2);
--badge-admin-text: #93c5fd; --badge-admin-text: #93c5fd;
} }
[data-theme="dark"] body { background: var(--bg-page); color: var(--text-main); } [data-theme="dark"] body {
[data-theme="dark"] .pf-header { background: var(--bg-panel); border-color: var(--border-light); } background: var(--bg-page);
[data-theme="dark"] .pf-logo { color: var(--primary); } color: var(--text-main);
[data-theme="dark"] .pf-nav-link { color: var(--text-muted); } }
[data-theme="dark"] .pf-nav-link:hover { background: rgba(255,255,255,.06); color: var(--text-main); } [data-theme="dark"] .pf-header {
[data-theme="dark"] .pf-nav-link--active { background: rgba(59,130,246,.15); color: var(--primary); } background: var(--bg-panel);
[data-theme="dark"] .pf-user-badge { color: var(--text-muted); } border-color: var(--border-light);
[data-theme="dark"] .pf-logout-btn { color: #f87171; } }
[data-theme="dark"] .pf-logout-btn:hover { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.3); } [data-theme="dark"] .pf-logo {
[data-theme="dark"] .pf-burger-btn { color: var(--text-main); } color: var(--primary);
[data-theme="dark"] .pf-mobile-menu { background: var(--bg-panel); color: var(--text-main); } }
[data-theme="dark"] .pf-mobile-menu-header { background: var(--bg-page); border-color: var(--border-light); } [data-theme="dark"] .pf-nav-link {
[data-theme="dark"] .pf-mobile-nav-link { color: var(--text-main); border-color: var(--border-light); } color: var(--text-muted);
[data-theme="dark"] .pf-mobile-nav-link:active { background: rgba(255,255,255,.06); } }
[data-theme="dark"] .pf-module-card { background: var(--bg-panel); border-color: var(--border-light); } [data-theme="dark"] .pf-nav-link:hover {
[data-theme="dark"] .pf-module-card:hover { border-color: #484f58; box-shadow: var(--shadow); } background: rgba(255, 255, 255, 0.06);
[data-theme="dark"] .pf-card-title { color: var(--text-main); } color: var(--text-main);
[data-theme="dark"] .pf-card-desc { color: var(--text-muted); } }
[data-theme="dark"] h1, [data-theme="dark"] h2, [data-theme="dark"] h3 { color: var(--text-main); } [data-theme="dark"] .pf-nav-link--active {
[data-theme="dark"] p { color: var(--text-muted); } background: rgba(59, 130, 246, 0.15);
[data-theme="dark"] .pf-btn.btn-secondary { background: var(--bg-panel); color: var(--text-muted); border-color: var(--border-light); } color: var(--primary);
[data-theme="dark"] .pf-btn.btn-secondary:hover { background: rgba(255,255,255,.05); color: var(--text-main); border-color: #484f58; } }
[data-theme="dark"] .pf-input { background: #1c2128; border-color: var(--border-light); color: var(--text-main); } [data-theme="dark"] .pf-user-badge {
[data-theme="dark"] .pf-input::placeholder { color: #484f58; } color: var(--text-muted);
[data-theme="dark"] .pf-input:focus { background: #1c2128; border-color: var(--primary); } }
[data-theme="dark"] .pf-table { background: var(--bg-panel); } [data-theme="dark"] .pf-logout-btn {
[data-theme="dark"] .pf-table th { background: #1c2128; color: var(--text-muted); border-color: var(--border-light); } color: #f87171;
[data-theme="dark"] .pf-table td { border-color: rgba(48,54,61,.5); color: var(--text-main); } }
[data-theme="dark"] .pf-table tbody tr:hover { background: rgba(255,255,255,.03); } [data-theme="dark"] .pf-logout-btn:hover {
[data-theme="dark"] .pf-modal-content { background: var(--bg-panel); border: 1px solid var(--border-light); } background: rgba(239, 68, 68, 0.1);
[data-theme="dark"] .pf-modal-title { border-color: var(--border-light); color: var(--text-main); } border-color: rgba(239, 68, 68, 0.3);
[data-theme="dark"] .modal-footer { border-color: var(--border-light); } }
[data-theme="dark"] .pf-footer { background: var(--bg-panel); border-color: var(--border-light); } [data-theme="dark"] .pf-burger-btn {
[data-theme="dark"] .pf-toast { background: var(--bg-panel); color: var(--text-main); box-shadow: 0 10px 25px rgba(0,0,0,.4); } color: var(--text-main);
[data-theme="dark"] .pf-login-card { background: var(--bg-panel); border-color: var(--border-light); } }
[data-theme="dark"] .pf-login-error { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.3); } [data-theme="dark"] .pf-mobile-menu {
[data-theme="dark"] .btn-icon-action.edit:hover { background: rgba(59,130,246,.15); } background: var(--bg-panel);
[data-theme="dark"] .btn-icon-action.delete:hover { background: rgba(239,68,68,.1); } color: var(--text-main);
}
[data-theme="dark"] .pf-mobile-menu-header {
background: var(--bg-page);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-mobile-nav-link {
color: var(--text-main);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-mobile-nav-link:active {
background: rgba(255, 255, 255, 0.06);
}
[data-theme="dark"] .pf-module-card {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-module-card:hover {
border-color: #484f58;
box-shadow: var(--shadow);
}
[data-theme="dark"] .pf-card-title {
color: var(--text-main);
}
[data-theme="dark"] .pf-card-desc {
color: var(--text-muted);
}
[data-theme="dark"] h1,
[data-theme="dark"] h2,
[data-theme="dark"] h3 {
color: var(--text-main);
}
[data-theme="dark"] p {
color: var(--text-muted);
}
[data-theme="dark"] .pf-btn.btn-secondary {
background: var(--bg-panel);
color: var(--text-muted);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-btn.btn-secondary:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
border-color: #484f58;
}
[data-theme="dark"] .pf-input {
background: #1c2128;
border-color: var(--border-light);
color: var(--text-main);
}
[data-theme="dark"] .pf-input::placeholder {
color: #484f58;
}
[data-theme="dark"] .pf-input:focus {
background: #1c2128;
border-color: var(--primary);
}
[data-theme="dark"] .pf-table {
background: var(--bg-panel);
}
[data-theme="dark"] .pf-table th {
background: #1c2128;
color: var(--text-muted);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-table td {
border-color: rgba(48, 54, 61, 0.5);
color: var(--text-main);
}
[data-theme="dark"] .pf-table tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
[data-theme="dark"] .pf-modal-content {
background: var(--bg-panel);
border: 1px solid var(--border-light);
}
[data-theme="dark"] .pf-modal-title {
border-color: var(--border-light);
color: var(--text-main);
}
[data-theme="dark"] .modal-footer {
border-color: var(--border-light);
}
[data-theme="dark"] .pf-footer {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-toast {
background: var(--bg-panel);
color: var(--text-main);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] .pf-login-card {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-login-error {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
[data-theme="dark"] .btn-icon-action.edit:hover {
background: rgba(59, 130, 246, 0.15);
}
[data-theme="dark"] .btn-icon-action.delete:hover {
background: rgba(239, 68, 68, 0.1);
}
/* ─── Pill toggle thème ────────────────────────────────────── */ /* ─── Pill toggle thème ────────────────────────────────────── */
.theme-switch { .theme-switch {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex;
background: none; border: none; cursor: pointer; align-items: center;
padding: 3px 5px; border-radius: 8px; transition: background .15s; gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 8px;
transition: background 0.15s;
}
.theme-switch:hover {
background: #f1f5f9;
}
[data-theme="dark"] .theme-switch:hover {
background: rgba(255, 255, 255, 0.08);
}
.ts-sun,
.ts-moon {
font-size: 0.85rem;
line-height: 1;
transition: opacity 0.2s;
user-select: none;
}
[data-theme="dark"] .ts-sun {
opacity: 0.3;
}
.ts-moon {
opacity: 0.3;
}
[data-theme="dark"] .ts-moon {
opacity: 1;
} }
.theme-switch:hover { background: #f1f5f9; }
[data-theme="dark"] .theme-switch:hover { background: rgba(255,255,255,.08); }
.ts-sun, .ts-moon { font-size: .85rem; line-height: 1; transition: opacity .2s; user-select: none; }
[data-theme="dark"] .ts-sun { opacity: .3; }
.ts-moon { opacity: .3; }
[data-theme="dark"] .ts-moon { opacity: 1; }
.ts-track { .ts-track {
position: relative; width: 36px; height: 20px; position: relative;
background: #cbd5e1; border-radius: 999px; width: 36px;
flex-shrink: 0; transition: background .25s; height: 20px;
background: #cbd5e1;
border-radius: 999px;
flex-shrink: 0;
transition: background 0.25s;
}
[data-theme="dark"] .ts-track {
background: #2563eb;
} }
[data-theme="dark"] .ts-track { background: #2563eb; }
.ts-thumb { .ts-thumb {
position: absolute; top: 3px; left: 3px; position: absolute;
width: 14px; height: 14px; border-radius: 50%; top: 3px;
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); left: 3px;
transition: transform .25s cubic-bezier(.4,0,.2,1); width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
[data-theme="dark"] .ts-thumb {
transform: translateX(16px);
}
/* === ACCORDÉONS NATIFS (DETAILS/SUMMARY) === */
.pf-accordion {
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
transition: all 0.3s ease;
}
.pf-accordion-summary {
padding: 14px 16px;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
cursor: pointer;
list-style: none; /* Masque la flèche native Firefox/Chrome */
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-page);
transition:
background 0.2s,
color 0.2s;
}
.pf-accordion-summary::-webkit-details-marker {
display: none; /* Masque la flèche native Safari */
}
.pf-accordion-summary:hover {
background: var(--bg-subtle);
color: var(--text-main);
}
.pf-accordion-summary::after {
content: "▼";
font-size: 0.7rem;
transition: transform 0.3s ease;
}
details[open].pf-accordion > .pf-accordion-summary {
border-bottom: 1px solid var(--border-light);
color: var(--primary);
}
details[open].pf-accordion > .pf-accordion-summary::after {
transform: rotate(-180deg);
}
.pf-accordion-content {
padding: 16px;
background: var(--bg-panel);
display: flex;
flex-direction: column;
gap: 15px;
}
/* Bouton Paramètres avec icône rotative (Roue crantée) */
.btn-settings-gear {
background: transparent;
border: none;
box-shadow: none;
outline: none;
font-size: 1.4rem;
padding: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
line-height: 1;
color: inherit;
}
.btn-settings-gear:hover {
transform: rotate(
90deg
); /* 90deg rend mieux sur une roue crantée qu'un 45deg ! */
} }
[data-theme="dark"] .ts-thumb { transform: translateX(16px); }
+5 -3
View File
@@ -130,10 +130,12 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
*/ */
window.I18N = <?php echo json_encode($current_translations_array ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE); ?>; 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 (évite le crash si config.php n'est pas chargé) // ✅ Injection sécurisée
window.CONFIG = { window.CONFIG = {
ID_ALEX: <?php echo defined('ID_ALEX') ? ID_ALEX : 2; ?>, // Liste complète des profils de la famille (Membres, Nounou, Enfants)
ID_LAIA: <?php echo defined('ID_LAIA') ? ID_LAIA : 3; ?>, 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 : "€"; ?>', CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>',
ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>' ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>'
}; };
+16 -2
View File
@@ -22,6 +22,9 @@ $pageCss = "/modules/holidays/holidays.css";
require __DIR__ . '/header.php'; require __DIR__ . '/header.php';
$stmtVehicles = $pdo->query("SELECT id, name FROM pf_vehicles ORDER BY name ASC");
$garageVehicles = $stmtVehicles->fetchAll(PDO::FETCH_ASSOC);
// 3. ROUTEUR DU MODULE VACANCES // 3. ROUTEUR DU MODULE VACANCES
if ($tab === 'holiday_detail' && isset($_GET['id'])) { if ($tab === 'holiday_detail' && isset($_GET['id'])) {
// Si on demande le détail ET qu'un ID est fourni // Si on demande le détail ET qu'un ID est fourni
@@ -31,7 +34,18 @@ if ($tab === 'holiday_detail' && isset($_GET['id'])) {
require __DIR__ . '/modules/holidays/views/list.php'; require __DIR__ . '/modules/holidays/views/list.php';
} }
// 4. Inclusion du JS global du module (Pont i18n déjà géré dans le header) // 4. Inclusion des librairies globales du module (Flatpickr)
echo '<script src="/modules/holidays/holidays.js"></script>'; ?>
<!-- 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'; require __DIR__ . '/footer.php';
+5
View File
@@ -54,3 +54,8 @@ function verify_csrf(?string $token): bool
} }
return hash_equals($_SESSION['csrf_token'], $token); return hash_equals($_SESSION['csrf_token'], $token);
} }
if (!isset($familyPeople) && !empty($family_id)) {
require_once __DIR__ . '/db.php';
$stmtPeople = $pdo->query("SELECT id, name, user_id FROM pf_people ORDER BY id ASC");
$familyPeople = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
}
-7
View File
@@ -1,7 +0,0 @@
<?php
// includes/config.php
// HouseHub Config - Version dynamique
if (!defined('ID_ALEX')) {
define('ID_ALEX', 2);
define('ID_LAIA', 3);
}
+37 -34
View File
@@ -1,56 +1,59 @@
<?php <?php
// includes/db.php
if (file_exists(__DIR__ . '/config.php')) { if (file_exists(__DIR__ . '/config.php')) {
require_once __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) { if (session_status() === PHP_SESSION_NONE) {
session_start(); session_start();
} }
$host = getenv('DB_HOST') ?: 'househub-db'; $envHost = getenv('DB_HOST');
$user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme';
$db = $_SESSION['family_db'] ?? null;
if (!$db) { // 1. Détection stricte de l'environnement
$current = basename($_SERVER['PHP_SELF'] ?? ''); if ($envHost === 'househub-db') {
if (!in_array($current, ['login.php', 'register.php'])) { // Docker
header('Location: /login.php'); $host = 'househub-db';
exit; $user = getenv('DB_USER') ?: 'househub';
} $pass = getenv('DB_PASS') ?: 'changeme';
return; } else {
// XAMPP
$host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
$user = 'root';
$pass = '';
} }
try { // 2. Multi-tenant strict : cibler la DB de la famille courante
$pdo = new PDO( $db = 'househub_meta'; // Fallback par défaut
"mysql:host=$host;dbname=$db;charset=utf8mb4", if (!empty($_SESSION['user']['family_id'])) {
$user, $pass, $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_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci", PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30, PDO::ATTR_TIMEOUT => 30,
] ];
);
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
try { try {
$foyer = $pdo->query("SELECT currency, zone_scolaire FROM pf_foyer_settings WHERE id = 1")->fetch(); $pdo = new PDO($dsn, $user, $pass, $options);
if (!defined('CURRENCY')) {
define('CURRENCY', $foyer['currency'] ?? '€'); // Forcer la collation
} $pdo->exec("SET collation_connection = utf8mb4_general_ci");
if (!defined('ZONE_SCOLAIRE')) { $pdo->exec("SET collation_database = utf8mb4_general_ci");
define('ZONE_SCOLAIRE', $foyer['zone_scolaire'] ?? 'C'); $pdo->exec("SET collation_server = utf8mb4_general_ci");
}
} catch (\PDOException $e) {
if (!defined('CURRENCY')) define('CURRENCY', '€');
if (!defined('ZONE_SCOLAIRE')) define('ZONE_SCOLAIRE', 'C');
}
// ------------------------------------------------
} catch (\PDOException $e) { } catch (\PDOException $e) {
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); } die("Erreur de connexion BDD (Host: $host, DB: $db) : " . $e->getMessage());
die(json_encode(['ok' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]));
} }
?> ?>
+359 -38
View File
@@ -156,6 +156,20 @@ return [
'mod_planka_desc' => 'Gestioneu els vostres projectes en mode Kanban amb Planka.', 'mod_planka_desc' => 'Gestioneu els vostres projectes en mode Kanban amb Planka.',
'menu_planka' => 'Planka', 'menu_planka' => 'Planka',
// --- SETTINGS : ENFANTS ---
'set_kids_title' => 'Nens de la llar',
'set_kids_desc' => 'Gestioneu la llista de nens per associar-los a l\'estalvi, als regals, etc.',
'set_add_kid_placeholder' => 'Nom del nen',
'set_btn_add_kid' => 'Afegir',
'set_err_kid_name' => 'El nom és obligatori.',
'set_confirm_del_kid' => 'Vols eliminar aquest nen? (El seu historial financer es conservarà als arxius).',
'set_no_kids' => 'Cap nen configurat per a aquesta llar.',
'set_family_config' => 'Configuració de la llar',
'set_global_params' => 'Paràmetres globals',
'set_care_modes' => 'Modalitats de cura habituals',
'set_add_care_mode' => 'Afegir una modalitat',
'btn_save' => 'Desar',
// ========================================== // ==========================================
// FAMILY CALENDAR // FAMILY CALENDAR
// ========================================== // ==========================================
@@ -199,6 +213,122 @@ return [
'fc_err_action' => 'Error en l\'acció: ', 'fc_err_action' => 'Error en l\'acció: ',
'fc_today_short' => 'Avui', 'fc_today_short' => 'Avui',
'fc_today_title' => 'Anar a avui', 'fc_today_title' => 'Anar a avui',
'fc_tab_foyer' => '🏡 Llar',
'fc_tab_members' => '👥 Membres i Drets',
'fc_select_member' => 'Selecciona un membre de la família:',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Nen',
'fc_care_modes_title' => '👶 Modes de cura associats',
'fc_care_modes_desc' => 'Marca els modes de cura que s\'apliquen a aquest nen.',
'fc_no_care_mode' => 'Cap mode configurat.',
'fc_matrix_title' => '👨‍👩‍👦 Matriu de Drets de Permís',
'fc_matrix_desc' => 'Declara els codis de comptadors i les dates de renovació.',
'fc_matrix_code' => 'Codi',
'fc_matrix_date' => 'Data de Reset',
'fc_matrix_empty' => 'Cap comptador configurat.',
'fc_matrix_saved' => 'Matriu de drets actualitzada! 💼',
'fc_child_saved' => 'Subscripció del nen desada!',
'btn_add_counter' => ' Afegir',
'btn_save_rights' => '💾 Desar',
'fc_modal_settings_title' => 'Configuració del Calendari',
'fc_zone_label' => 'Zona Escolar (Vacances)',
'fc_zone_desc' => 'Determina el calendari automàtic de vacances escolars.',
'fc_care_modes_label' => 'Modes de Cura de la Llar',
'fc_care_modes_desc' => 'Gestiona la llista global de modes de cura possibles per a la família.',
'fc_add_mode_placeholder' => 'Ex: Mainadera, Casal, Avis...',
'fc_settings_updated' => 'Configuració actualitzada correctament!',
'fc_first_day' => 'Primer dia de la setmana',
// --- CALENDARI : PARÀMETRES (MEMBRES I VISUALITZACIÓ) ---
'fc_tab_display' => '🖥️ Visualització',
'fc_display_title' => 'Ajustos de visualització',
'fc_display_desc' => 'Personalitza l\'aparença i el comportament predeterminat del teu calendari.',
'fc_default_view' => 'Vista per defecte',
'fc_view_month' => '1 mes',
'fc_view_2months' => '2 mesos',
'fc_view_3months' => '3 mesos',
'fc_working_hours' => 'Horari de cura (Per defecte)',
'fc_working_hours_desc' => 'Exemple: 08:30-18:00',
'fc_role_unknown' => 'Desconegut',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Nen/a',
'fc_role_helper' => 'Ajudant',
'fc_error' => 'Error: ',
'fc_care_modes_title' => 'Modes de Cura Predeterminats',
'fc_care_modes_desc' => 'Quins modes de cura s\'apliquen a aquest nen/a?',
'fc_no_care_mode' => 'No hi ha modes de cura configurats a la llar.',
'fc_child_saved' => 'Desat!',
'fc_matrix_title' => 'Matriu de Permisos',
'fc_matrix_desc' => 'Defineix les quotes i dates de renovació.',
'fc_matrix_empty' => 'No hi ha comptadors definits.',
'fc_matrix_saved' => 'Matriu desada!',
'fc_leave_obsolete' => 'Obsolet',
'fc_leave_code_placeholder' => 'Codi...',
'fc_leave_method_fixed' => 'Fix',
'fc_leave_method_accumulated' => 'Acumulat',
'fc_date_format_placeholder' => 'DD/MM',
'fc_date_format_title' => 'Format DD/MM',
'fc_table_code' => 'Codi',
'fc_table_method' => 'Mètode',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renov.',
'btn_save_rights' => 'Guardar',
// ==========================================
// MODAL DE CONFIGURACIÓ DEL CALENDARI
// ==========================================
'fc_modal_settings_title' => 'Configuració del calendari',
'fc_tab_foyer' => 'Llar',
'fc_tab_members' => 'Membres i Drets',
// Llar: Zona escolar
'fc_zone_label' => 'Zona escolar',
'fc_zone_a' => 'Zona A',
'fc_zone_b' => 'Zona B',
'fc_zone_c' => 'Zona C',
'fc_zone_other' => 'Altra / Fora de França',
'fc_zone_desc' => 'Permet mostrar correctament les vacances escolars.',
// Llar: Opcions de cura (Modes de garde)
'fc_care_modes_label' => 'Modalitats de cura',
'fc_add_mode_placeholder' => 'Ex: Mainadera, Llar d\'infants...',
'fc_care_modes_desc' => 'Afegeix les diferents modalitats de cura per als teus fills.',
'fc_empty_kids_msg' => 'Actualment no hi ha cap infant configurat a la teva família.',
'fc_link_settings' => 'Anar a la configuració general',
// Llar: Catàleg de permisos (Congés)
'fc_leave_catalog_title' => 'Catàleg de permisos',
'fc_leave_catalog_desc' => 'Defineix els tipus de permisos possibles per a tota la família.',
'fc_add_leave_type' => 'Afegir un tipus de permís',
'fc_col_code' => 'Codi',
'fc_code_irreversible' => 'Irreversible',
'fc_col_label' => 'Etiqueta',
'fc_leave_label_ph' => 'Ex: Vacances, Assumptes propis...',
'fc_quota_default' => 'Quota per defecte',
'fc_reset_month' => 'Mes de renovació',
// Membres
'fc_select_member' => 'Selecciona un membre:',
// Mesos
'month_jan' => 'Gener',
'month_feb' => 'Febrer',
'month_mar' => 'Març',
'month_apr' => 'Abril',
'month_may' => 'Maig',
'month_jun' => 'Juny',
'month_jul' => 'Juliol',
'month_aug' => 'Agost',
'month_sep' => 'Setembre',
'month_oct' => 'Octubre',
'month_nov' => 'Novembre',
'month_dec' => 'Desembre',
'leg_presence' => 'Presència Pep', 'leg_presence' => 'Presència Pep',
'leg_school_holidays' => 'Vacances Escolars', 'leg_school_holidays' => 'Vacances Escolars',
@@ -208,6 +338,9 @@ return [
'leg_centre' => 'Casal', 'leg_centre' => 'Casal',
'leg_avis' => 'Avís', 'leg_avis' => 'Avís',
'leg_pep_sick' => 'Pep Malalt', 'leg_pep_sick' => 'Pep Malalt',
'care_nounou' => 'Kangur',
'care_centre' => 'Casal',
'care_avis' => 'Avis',
'vac_toussaint' => 'Vacances de Tots Sants', 'vac_toussaint' => 'Vacances de Tots Sants',
'vac_noel' => 'Vacances de Nadal', 'vac_noel' => 'Vacances de Nadal',
@@ -385,6 +518,71 @@ return [
'cat_fixed' => 'Despeses Fixes', 'cat_fixed' => 'Despeses Fixes',
'cat_others' => 'Altres / Imprevistos', 'cat_others' => 'Altres / Imprevistos',
'cat_savings' => 'Estalvis', 'cat_savings' => 'Estalvis',
'budget_tab_kids' => 'Nens',
'budget_settings_title' => 'Configuració del Pressupost',
'bs_tab_accounts' => '💳 Comptes',
'bs_tab_categories' => '🏷️ Categories',
'bs_tab_rules' => '🤖 Regles d\'importació',
'bs_tab_salaries' => '💼 Salaris',
'bs_empty_accounts' => 'Cap compte configurat.',
'bs_type_checking' => 'Corrent',
'bs_type_savings' => 'Estalvi',
'bs_badge_default' => 'Per defecte',
'bs_owner' => 'Propietari',
'bs_shared_account' => 'Compte conjunt',
'bs_empty_categories' => 'Cap categoria configurada.',
'bs_type_income' => 'Ingrés',
'bs_type_expense' => 'Despesa',
'bs_type_savings_cat' => 'Estalvi',
'bs_empty_rules' => 'Cap regla d\'importació.',
'bs_empty_salaries' => 'Cap salari definit per a l\'any',
'bs_salaries_year_desc' => 'Configuracions per a l\'any',
'bs_salary_net' => 'Salari net',
'bs_salary_fees' => 'Mensualitat / Despeses',
'bs_add_account_title' => 'Afegir un compte',
'bs_account_name_ph' => 'Nom (ex: Estalvi Bebè)',
'bs_confirm_delete' => 'Segur que voleu suprimir aquest compte?',
'bs_add_category_title' => '+ Afegir una categoria',
'bs_cat_code_ph' => 'Codi (ex: FOOD)',
'bs_cat_label_ph' => 'Etiqueta (ex: Compres)',
'bs_cat_icon_ph' => 'Icona (ex: 🛒)',
'bs_confirm_delete_cat' => 'Segur que voleu suprimir aquesta categoria?',
'bs_add_rule_title' => 'Afegir una regla',
'bs_rule_keyword_ph' => 'Paraula clau (ex: AUCHAN)',
'bs_confirm_delete_rule'=> 'Segur que voleu suprimir aquesta regla?',
'bs_currency_label' => 'Divisa de la llar',
'bs_currency_desc' => 'Divisa principal utilitzada per mostrar els vostres comptes i pressupostos.',
'bs_currency_updated' => 'Divisa actualitzada correctament!',
// ONGLET CSV MAPPING
'bs_csv_title' => 'Format de la importació CSV',
'bs_csv_desc' => 'Configureu aquí les columnes que corresponen a l\'exportació del vostre banc.',
'bs_csv_delimiter' => 'Separador',
'bs_csv_delim_semi' => 'Punt i coma (;)',
'bs_csv_delim_comma' => 'Coma (,)',
'bs_csv_delim_tab' => 'Tabulació',
'bs_csv_date_format' => 'Format de la Data',
'bs_csv_col_index' => 'Índex de les columnes',
'bs_csv_col_index_help' => '(Atenció: la 1a columna és la Número 0)',
'bs_csv_col_date' => 'Núm Col: Data',
'bs_csv_col_label' => 'Núm Col: Concepte / Motiu',
'bs_csv_amount_mgmt' => 'Gestió dels imports',
'bs_csv_amount_single' => 'Una sola columna (Positiu = Ingrés, Negatiu = Despesa)',
'bs_csv_amount_split' => 'Dues columnes separades (Dèbit / Crèdit)',
'bs_csv_col_amount' => 'Núm Col: Import',
'bs_csv_col_debit' => 'Núm Col: Dèbit',
'bs_csv_col_credit' => 'Núm Col: Crèdit',
'bs_csv_col_ref' => 'Núm Col: Referència única (Opcional)',
'btn_save_format' => 'Desar el format',
'bs_csv_saved' => 'Format CSV desat',
'bs_tab_csv' => '📥 Importació CSV',
'bs_csv_settings_title' => 'Configuració del format',
'bs_csv_drop_title' => 'Arrossegueu el vostre fitxer CSV aquí',
'bs_csv_drop_desc' => 'o feu clic per cercar fitxers',
'bs_csv_err_type' => 'Error: Només s\'admeten fitxers .csv.',
'bs_csv_file_ok' => 'Fitxer carregat',
'bs_csv_preview_title' => 'Previsualització de les vostres columnes',
'bs_csv_empty_col' => 'Buit',
'bs_csv_no_data' => 'No s\'ha trobat cap línia de transacció en aquest fitxer.',
// --- BUDGET : SUIVI --- // --- BUDGET : SUIVI ---
'bud_rem_school' => 'Escola (Resta estimada)', 'bud_rem_school' => 'Escola (Resta estimada)',
@@ -442,6 +640,26 @@ return [
'bud_bar_actual' => "Actual", 'bud_bar_actual' => "Actual",
'bud_bar_theoretical' => "Teòric", 'bud_bar_theoretical' => "Teòric",
'bud_bar_max_cap' => "Capacitat Màx.", 'bud_bar_max_cap' => "Capacitat Màx.",
'bud_edit_title' => 'Modificar la transacció',
'bud_to_define_js' => 'per definir',
'bud_select_beneficiary' => 'Triar un ingrés esperat',
'bud_bar_actual' => 'Actual',
// ONBOARDING BUDGET
'bs_onb_title' => 'Benvinguts! Configurem el teu pressupost:',
'bs_onb_desc' => 'Completa aquests passos perquè l\'aplicació pugui calcular el teu pressupost disponible:',
'bs_onb_cat_link' => 'Categories',
'bs_onb_cat_desc' => 'Crea almenys una categoria de despeses.',
'bs_onb_acc_link' => 'Comptes Bancaris',
'bs_onb_acc_desc' => 'Afegeix almenys un compte corrent o d\'estalvi.',
'bs_onb_sal_link' => 'Sous',
'bs_onb_sal_desc' => 'Introdueix els ingressos de l\'any en curs.',
'bs_onb_items_link' => 'Línies de Pressupost',
'bs_onb_items_desc' => 'Ves a la pestanya Resum per llistar les teves despeses fixes (Lloguer, Assegurances...).',
'bs_rule_search_ph' => '🔍 Cercar una paraula clau (ex: AUCHAN)...',
'error_loading_settings' => 'Error en carregar la configuració',
'settings' => 'Configuració',
// --- MODULE BUDGET : IMPORT CSV --- // --- MODULE BUDGET : IMPORT CSV ---
'bud_import_csv' => "Importar CSV", 'bud_import_csv' => "Importar CSV",
'bud_csv_file' => "Seleccioneu el fitxer del vostre banc (.csv)", 'bud_csv_file' => "Seleccioneu el fitxer del vostre banc (.csv)",
@@ -499,6 +717,9 @@ return [
'bud_sav_modal_title_add' => 'Introduir un mes', 'bud_sav_modal_title_add' => 'Introduir un mes',
'bud_sav_ph_name' => 'Nom (ex: Vacances)', 'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_prev_label_name' => 'Nom', 'bud_prev_label_name' => 'Nom',
'bud_prev_monthly_target' => 'Import objectiu / Mensualitat (€)',
'bud_prev_transfer_dest' => 'Destinació de la transferència',
'bud_prev_none' => 'Cap destinació (Despesa estàndard)',
// --- BUDGET : EPARGNE --- // --- BUDGET : EPARGNE ---
'bud_sav_add_month' => 'Introduir un mes', 'bud_sav_add_month' => 'Introduir un mes',
@@ -529,6 +750,60 @@ return [
'bud_sav_prompt_duplicate' => "Vols copiar les dades de %s a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:", 'bud_sav_prompt_duplicate' => "Vols copiar les dades de %s a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
'bud_err_server' => 'Error del servidor: ', 'bud_err_server' => 'Error del servidor: ',
'bud_err_network_dup' => 'Error de xarxa en duplicar.', 'bud_err_network_dup' => 'Error de xarxa en duplicar.',
'budget_tab_kids' => 'Infants 👶',
'bud_sav_add_one_month' => '+1 Mes',
'bud_sav_add_month' => 'Afegir un mes',
'bud_sav_no_data' => 'Cap dada d\'estalvi per a %s.',
'bud_sav_post_month' => 'Concepte / Mes',
'bud_sav_from_date' => 'Des del %s',
'bud_sav_edit_modal' => 'Editar aquest mes',
'bud_sav_delete_month' => 'Suprimir aquest mes',
'bud_sav_total_bank' => 'TOTAL BANC',
'bud_sav_extra' => 'NO ASSIGNAT (EXTRES)',
'bud_sav_modal_title_add' => 'Afegir un mes',
'bud_sav_modal_title_edit' => 'Editar',
'bud_sav_month_concerned' => 'Mes afectat',
'bud_sav_total_bank_eur' => 'Total al Banc (€)',
'bud_sav_ventilation' => 'Desglossament de comptes',
'bud_sav_adj_help' => 'Utilitzeu l\'ajust (+/-) per recalcular automàticament.',
'bud_sav_add_line' => 'Afegir una línia',
'bud_category' => 'Categoria',
'bud_sav_current' => 'Actual',
'bud_sav_adjust' => 'Ajust',
'bud_sav_new' => 'Nou',
'bud_sav_sum_mode_title' => 'Activar/Desactivar la calculadora',
'bud_sav_selection' => 'Selecció:',
'bud_sav_ph_name' => 'Nom del compte',
// --- Module Budget : Provisions & Optimisation ---
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
'btn_optimize_cashflow' => 'Optimiser mes Économies',
'add_new_provision' => 'Anticiper une grosse dépense à venir',
'provision_label' => 'Libellé de la dépense',
'provision_placeholder_wood' => 'Ex: Commande de bois, Révision de l\'auto...',
'amount' => 'Montant',
'expected_date' => 'Date prévue',
'btn_add' => 'Ajouter',
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
'error_generic' => 'Une erreur est survenue',
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
'btn_delete' => 'Supprimer',
'savings_this_month' => 'Économies globales à placer ce mois-ci',
'calculate' => 'Calculer la stratégie optimale',
'date_past_warning' => 'La date sélectionnée est déjà passée !',
'budget_tab_provisions' => 'Provisions',
// JS & Errors
'bud_sav_confirm_delete_month' => 'Estàs segur que vols suprimir totes les dades de %m per a %o?',
'bud_sav_prompt_duplicate' => "Vols duplicar les dades de %s cap a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
'bud_err_tech' => 'S\'ha produït un error tècnic.',
'bud_err_server' => 'Error del servidor: ',
'bud_err_network_dup' => 'Error de xarxa en duplicar.',
'bud_sav_saving' => 'Desant...',
'bud_err_delete' => 'Error en suprimir.',
// --- BUDGET : PREVISIONNEL --- // --- BUDGET : PREVISIONNEL ---
'bud_prev_incomes' => 'Ingressos', 'bud_prev_incomes' => 'Ingressos',
@@ -564,49 +839,95 @@ return [
'bud_prev_err_no_history' => 'Impossible: no hi ha historial disponible per copiar.', 'bud_prev_err_no_history' => 'Impossible: no hi ha historial disponible per copiar.',
'bud_prev_confirm_copy' => "Vols copiar les dades de %s a %t?\n\n⚠️ Això sobreescriurà tots els valors ja presents per a %t.", 'bud_prev_confirm_copy' => "Vols copiar les dades de %s a %t?\n\n⚠️ Això sobreescriurà tots els valors ja presents per a %t.",
'bud_prev_confirm_transfers' => "Confirmes que %p ha fet totes les transferències per a %m?\n\nAixò actualitzarà l'Estalvi automàticament.", 'bud_prev_confirm_transfers' => "Confirmes que %p ha fet totes les transferències per a %m?\n\nAixò actualitzarà l'Estalvi automàticament.",
'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_transfer_to' => 'Cap a',
// --- MODULE BUDGET : AVANCES & TRICOUNT ---
'bud_adv_title' => 'Avançaments i Tricount Familiar',
'bud_adv_add' => 'Afegir un avançament',
'bud_adv_edit' => 'Modificar l\'avançament',
'bud_adv_who_paid' => 'Qui ha pagat?',
'bud_adv_payer' => 'Pagador',
'bud_adv_has_advanced' => '%s ha avançat:',
'bud_adv_cc_label' => 'al compte comú',
'bud_adv_livret_label' => 'des del seu llibre d\'estalvis',
'bud_adv_cc_balance_title' => 'Equilibri del Compte Comú',
'bud_adv_owed_to' => 'S\'han de pagar **%s €** a **%s**',
'bud_adv_balanced' => 'Els comptes estan perfectament equilibrats!',
'bud_adv_saved_badge' => 'Estalvis',
'bud_adv_already_saved' => 'Aquests diners provenen d\'un llibre d\'estalvis',
'bud_adv_ph_desc' => 'Ex: Compres, Factura d\'electricitat...',
'bud_adv_confirm_resolve' => 'Confirmar el reemborsament complet d\'aquest avançament?',
'bud_adv_confirm_delete' => 'Eliminar definitivament aquest avançament?',
// ========================================== // ==========================================
// CADEAUX (GIFTS) // MÒDUL: REGALS (gift-list)
// ========================================== // ==========================================
'gift_page_title' => 'HouseHub - Llista de regals',
'gift_occ_tio' => 'Tió', // 1. Títols i capçaleres
'gift_occ_noel' => 'Nadal', 'gift_page_title' => 'Llista de Regals',
'gift_occ_rois' => 'Reis', 'gift_main_title' => 'Regals de %s', // %s serà substituït per l'any (ex: Regals de 2026)
'gift_occ_anniv' => 'Aniversari', 'settings' => 'Configuració',
'gift_occ_sant' => 'Sant',
'gift_main_title' => 'Llista de regals %s', // 2. Filtres
'gift_aria_change_view' => 'Canvia la vista',
'gift_view_nadal' => 'Nadal',
'gift_view_anniv' => 'Aniversaris',
'gift_view_by_party' => 'Vista per festa',
'gift_no_gifts' => 'No hi ha cap regal registrat per a %s en aquesta vista.',
'gift_add_gift' => 'Afegeix un regal',
'gift_paid_by' => 'pagat per %s',
'gift_summary_title' => 'Resum del pressupost',
'gift_col_adult' => 'Adult',
'gift_col_child' => 'Infant',
'gift_col_party' => 'Festa',
'gift_debtor' => 'Deutor',
'gift_creditor' => 'Creditor',
'gift_liquidations' => 'Tricount',
'gift_no_debt' => 'Cap deute pendent.',
'gift_owes' => 'ha de pagar',
'gift_to' => 'a',
'gift_detailed_list' => 'Llista detallada de regals',
'gift_col_gift' => 'Regal',
'gift_col_link' => 'Enllaç',
'gift_modal_title_add' => 'Afegeix un regal per a %s',
'gift_modal_title_edit' => 'Edita el regal',
'gift_modal_payer' => 'Pagat per',
'gift_modal_gift_name' => 'Nom del regal',
'gift_modal_ph_name' => 'p. ex., Lego Star Wars',
'gift_modal_price' => 'Preu (€)',
'gift_modal_link' => 'Enllaç (opcional)',
'gift_confirm_delete' => 'Vols eliminar aquest regal?',
'gift_filter_all_children' => 'Tots els nens', 'gift_filter_all_children' => 'Tots els nens',
'gift_filter_all_adults' => 'Tots els adults', 'gift_filter_all_adults' => 'Tots els adults',
'gift_empty_state_no_gifts' => 'Cap regal de moment.',
'gift_empty_state_no_filter' => 'Cap regal correspon al filtre.', // 3. Contingut i estats buits
'gift_add_gift' => 'Afegir',
'gift_empty_state_no_gifts' => 'Cap regal registrat per a aquesta ocasió.',
'gift_empty_state_no_filter' => 'Cap regal coincideix amb els teus filtres.',
'gift_paid_by' => 'Pagat per %s', // %s serà substituït pel nom del pagador
// 4. Secció Tricount (Balanç i Reemborsaments)
'gift_liquidations' => 'Balanç i Reemborsaments',
'gift_no_debt' => 'Tot està al dia, cap deute!',
'gift_owes' => 'deu',
'gift_view_matrix' => 'Veure la matriu detallada',
'gift_debtor' => 'Deutor',
'gift_creditor' => 'Creditor',
// 5. Modal: Afegir/Modificar Regal
'gift_col_adult' => 'Per compte de',
'gift_modal_payer' => 'Pagat per',
'gift_modal_gift_name' => 'Descripció del regal',
'gift_modal_price' => 'Preu estimat / pagat (€)',
'gift_modal_link' => 'Enllaç del producte (URL)',
'gift_modal_title_add' => 'Afegir per a %s', // JS: %s = nom del nen
'gift_modal_title_edit' => 'Modificar el regal', // JS
'gift_confirm_delete' => 'Vols eliminar realment aquest regal?', // JS
// 6. Modal: Configuració de les Festes
'gift_settings_title' => 'Configuració de les Festes',
'gift_settings_add_title' => '+ Afegir una ocasió',
'gift_settings_name_placeholder' => 'Nom (ex: Sant Valentí)',
'gift_settings_date_placeholder' => 'MM-DD',
'gift_settings_date_help' => 'La data (MM-DD) és opcional. Deixa en blanc per a una festa mòbil.',
'gift_settings_empty' => 'Cap festa configurada.', // JS
'gift_settings_active' => 'Actiu', // JS
// 7. Botons genèrics
'btn_cancel' => 'Cancel·lar',
'btn_save' => 'Desar',
'btn_add' => 'Afegir',
'loading' => 'Carregant...', //
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings' => 'Configuració',
'gift_tab_occasions' => 'Festes i Ocasions',
'gift_tab_proches' => 'Familiars (Fora de la llar)',
'gift_settings_icon' => 'Icona',
'gift_settings_date' => 'Data (MM-DD)',
'gift_settings_add_occ' => 'Afegeix una festa',
'gift_settings_add_proche'=> 'Afegeix un familiar',
'gift_role_proche_adulte'=> 'Adult (Oncle, amic...)',
'gift_role_proche_enfant'=> 'Nen (Nebot, neboda...)',
'gift_ph_proche_name' => 'Nom',
'gift_role_label' => 'Rol',
'gift_settings_saved' => 'Configuració desada!',
'gift_settings_edit_occ' => 'Modificar la festa',
'gift_settings_edit_proche' => 'Modificar el familiar',
// ========================================== // ==========================================
// GARAGE MANAGER // GARAGE MANAGER
// ========================================== // ==========================================
+357 -38
View File
@@ -157,6 +157,20 @@ return [
'mod_planka_desc' => 'Manage your projects in Kanban mode with Planka.', 'mod_planka_desc' => 'Manage your projects in Kanban mode with Planka.',
'menu_planka' => 'Planka', 'menu_planka' => 'Planka',
// --- SETTINGS : KIDS ---
'set_kids_title' => 'Household Kids',
'set_kids_desc' => 'Manage the list of kids to associate them with savings, gifts, etc.',
'set_add_kid_placeholder' => 'Kid\'s first name',
'set_btn_add_kid' => 'Add',
'set_err_kid_name' => 'The first name is required.',
'set_confirm_del_kid' => 'Delete this kid? (Their financial history will remain visible in the archives).',
'set_no_kids' => 'No kids configured for this household.',
'set_family_config' => 'Family Structure',
'set_global_params' => 'Global settings',
'set_care_modes' => 'Common childcare arrangements',
'set_add_care_mode' => 'Ajouter un modeAdd a mode',
'btn_save' => 'Save',
// ========================================== // ==========================================
// FAMILY CALENDAR // FAMILY CALENDAR
// ========================================== // ==========================================
@@ -200,6 +214,122 @@ return [
'fc_err_action' => 'Error during action: ', 'fc_err_action' => 'Error during action: ',
'fc_today_short' => 'Today', 'fc_today_short' => 'Today',
'fc_today_title' => 'Today', 'fc_today_title' => 'Today',
'fc_tab_foyer' => '🏡 Household',
'fc_tab_members' => '👥 Members & Rights',
'fc_select_member' => 'Select a family member:',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Child',
'fc_care_modes_title' => 'Associated Care Modes',
'fc_care_modes_desc' => 'Check the care modes that apply to this child.',
'fc_no_care_mode' => 'No care mode configured.',
'fc_matrix_title' => '👨‍👩‍👦 Leave Rights Matrix',
'fc_matrix_desc' => 'Declare your counter codes and renewal dates.',
'fc_matrix_code' => 'Code',
'fc_matrix_date' => 'Reset Date',
'fc_matrix_empty' => 'No counter configured.',
'fc_matrix_saved' => 'Rights matrix updated successfully! 💼',
'fc_child_saved' => 'Child subscription saved!',
'btn_add_counter' => ' Add',
'btn_save_rights' => '💾 Save',
'fc_modal_settings_title' => 'Calendar Settings',
'fc_zone_label' => 'School Zone (Holidays)',
'fc_zone_desc' => 'Determines the automatic school holiday calendar.',
'fc_care_modes_label' => 'Household Care Modes',
'fc_care_modes_desc' => 'Manage the global list of possible care modes for the family.',
'fc_add_mode_placeholder' => 'E.g., Nanny, Center, Grandparents...',
'fc_settings_updated' => 'Settings updated successfully!',
'fc_first_day' => 'First day of the week',
// --- CALENDAR: SETTINGS (MEMBERS & DISPLAY) ---
'fc_tab_display' => '🖥️ Display',
'fc_display_title' => 'Display Settings',
'fc_display_desc' => 'Customize the default appearance and behavior of your calendar.',
'fc_default_view' => 'Default View',
'fc_view_month' => '1 month',
'fc_view_2months' => '2 months',
'fc_view_3months' => '3 months',
'fc_working_hours' => 'Default Care Hours',
'fc_working_hours_desc' => 'Example: 08:30-18:00',
'fc_role_unknown' => 'Unknown',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Child',
'fc_role_helper' => 'Helper',
'fc_error' => 'Error: ',
'fc_care_modes_title' => 'Default Care Modes',
'fc_care_modes_desc' => 'Which care modes apply to this child?',
'fc_no_care_mode' => 'No care mode configured for this household.',
'fc_child_saved' => 'Saved!',
'fc_matrix_title' => 'Leave Matrix',
'fc_matrix_desc' => 'Define quotas and renewal dates.',
'fc_matrix_empty' => 'No counters defined.',
"fc_matrix_saved" => "Matrix saved!",
'fc_leave_obsolete' => 'Obsolete',
'fc_leave_code_placeholder' => 'Code...',
'fc_leave_method_fixed' => 'Fixed',
'fc_leave_method_accumulated' => 'Accrued',
'fc_date_format_placeholder' => 'DD/MM',
'fc_date_format_title' => 'DD/MM Format',
'fc_table_code' => 'Code',
'fc_table_method' => 'Method',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renewal',
'btn_save_rights' => 'Save',
// ==========================================
// CALENDAR SETTINGS MODAL
// ==========================================
'fc_modal_settings_title' => 'Calendar Settings',
'fc_tab_foyer' => 'Household',
'fc_tab_members' => 'Members & Rights',
// Household: School Zone
'fc_zone_label' => 'School zone',
'fc_zone_a' => 'Zone A',
'fc_zone_b' => 'Zone B',
'fc_zone_c' => 'Zone C',
'fc_zone_other' => 'Other / Outside France',
'fc_zone_desc' => 'Used to accurately display school holidays.',
// Household: Care Modes
'fc_care_modes_label' => 'Childcare options',
'fc_add_mode_placeholder' => 'Ex: Nanny, Daycare...',
'fc_care_modes_desc' => 'Add the different childcare options for your children.',
'fc_empty_kids_msg' => 'No children are configured in your household yet.',
'fc_link_settings' => 'Go to general settings',
// Household: Leave Catalog
'fc_leave_catalog_title' => 'Leave Catalog',
'fc_leave_catalog_desc' => 'Define the possible leave types for the whole family.',
'fc_add_leave_type' => 'Add a leave type',
'fc_col_code' => 'Code',
'fc_code_irreversible' => 'Irreversible',
'fc_col_label' => 'Label',
'fc_leave_label_ph' => 'Ex: PTO, Sick leave...',
'fc_quota_default' => 'Default quota',
'fc_reset_month' => 'Reset month',
// Members
'fc_select_member' => 'Select a member:',
// Months
'month_jan' => 'January',
'month_feb' => 'February',
'month_mar' => 'March',
'month_apr' => 'April',
'month_may' => 'May',
'month_jun' => 'June',
'month_jul' => 'July',
'month_aug' => 'August',
'month_sep' => 'September',
'month_oct' => 'October',
'month_nov' => 'November',
'month_dec' => 'December',
'leg_presence' => 'Pep presence', 'leg_presence' => 'Pep presence',
'leg_school_holidays' => 'School holidays', 'leg_school_holidays' => 'School holidays',
@@ -209,6 +339,9 @@ return [
'leg_centre' => 'Centre', 'leg_centre' => 'Centre',
'leg_avis' => 'Notice', 'leg_avis' => 'Notice',
'leg_pep_sick' => 'Pep sick', 'leg_pep_sick' => 'Pep sick',
'care_nounou' => 'Nanny',
'care_centre' => 'Daycare',
'care_avis' => 'Grandparents',
'vac_toussaint' => 'All Saints\' holidays', 'vac_toussaint' => 'All Saints\' holidays',
'vac_noel' => 'Christmas holidays', 'vac_noel' => 'Christmas holidays',
@@ -383,6 +516,86 @@ return [
'cat_fixed' => 'Fixed costs', 'cat_fixed' => 'Fixed costs',
'cat_others' => 'Other / Unexpected', 'cat_others' => 'Other / Unexpected',
'cat_savings' => 'Savings', 'cat_savings' => 'Savings',
'budget_tab_kids' => 'Kids',
'budget_settings_title' => 'Budget Settings',
'bs_tab_accounts' => '💳 Accounts',
'bs_tab_categories' => '🏷️ Categories',
'bs_tab_rules' => '🤖 Import Rules',
'bs_tab_salaries' => '💼 Salaries',
'bs_empty_accounts' => 'No accounts configured.',
'bs_type_checking' => 'Checking',
'bs_type_savings' => 'Savings',
'bs_badge_default' => 'Default',
'bs_owner' => 'Owner',
'bs_shared_account' => 'Joint Account',
'bs_empty_categories' => 'No categories configured.',
'bs_type_income' => 'Income',
'bs_type_expense' => 'Expense',
'bs_type_savings_cat' => 'Savings',
'bs_empty_rules' => 'No import rules configured.',
'bs_empty_salaries' => 'No salary defined for the year',
'bs_salaries_year_desc' => 'Configurations for the year',
'bs_salary_net' => 'Net salary',
'bs_salary_fees' => 'Monthly payment / Fees',
'bs_add_account_title' => 'Add an account',
'bs_account_name_ph' => 'Name (e.g., Baby Savings)',
'bs_confirm_delete' => 'Are you sure you want to delete this account?',
'bs_add_category_title' => '+ Add a category',
'bs_cat_code_ph' => 'Code (e.g., FOOD)',
'bs_cat_label_ph' => 'Label (e.g., Groceries)',
'bs_cat_icon_ph' => 'Icon (e.g., 🛒)',
'bs_confirm_delete_cat' => 'Are you sure you want to delete this category?',
'bs_add_rule_title' => 'Add a rule',
'bs_rule_keyword_ph' => 'Keyword (e.g., WALMART)',
'bs_confirm_delete_rule'=> 'Are you sure you want to delete this rule?',
'bs_currency_label' => 'Household Currency',
'bs_currency_desc' => 'Main currency used for displaying your accounts and budgets.',
'bs_currency_updated' => 'Currency updated successfully!',
// ONGLET CSV MAPPING
'bs_csv_title' => 'CSV Import Format',
'bs_csv_desc' => 'Configure the columns that match your bank\'s export file.',
'bs_csv_delimiter' => 'Delimiter',
'bs_csv_delim_semi' => 'Semicolon (;)',
'bs_csv_delim_comma' => 'Comma (,)',
'bs_csv_delim_tab' => 'Tab',
'bs_csv_date_format' => 'Date Format',
'bs_csv_col_index' => 'Column Indexes',
'bs_csv_col_index_help' => '(Note: the 1st column is Number 0)',
'bs_csv_col_date' => 'Col #: Date',
'bs_csv_col_label' => 'Col #: Label / Description',
'bs_csv_amount_mgmt' => 'Amount Management',
'bs_csv_amount_single' => 'Single column (Positive = Income, Negative = Expense)',
'bs_csv_amount_split' => 'Two separate columns (Debit / Credit)',
'bs_csv_col_amount' => 'Col #: Amount',
'bs_csv_col_debit' => 'Col #: Debit',
'bs_csv_col_credit' => 'Col #: Credit',
'bs_csv_col_ref' => 'Col #: Unique Reference (Optional)',
'btn_save_format' => 'Save format',
'bs_csv_saved' => 'CSV format saved',
'bs_tab_csv' => '📥 CSV Import',
'bs_csv_settings_title' => 'Format Configuration',
'bs_csv_drop_title' => 'Drop your CSV file here',
'bs_csv_drop_desc' => 'or click to browse your files',
'bs_csv_err_type' => 'Error: Only .csv files are allowed.',
'bs_csv_file_ok' => 'File loaded',
'bs_csv_preview_title' => 'Columns Preview',
'bs_csv_empty_col' => 'Empty',
'bs_csv_no_data' => 'No transaction row found in this file.',
// ONBOARDING BUDGET
'bs_onb_title' => 'Welcome! Let\'s set up your budget:',
'bs_onb_desc' => 'Complete these steps so the app can calculate your remaining budget:',
'bs_onb_cat_link' => 'Categories',
'bs_onb_cat_desc' => 'Create at least one expense category.',
'bs_onb_acc_link' => 'Bank Accounts',
'bs_onb_acc_desc' => 'Add at least one checking or savings account.',
'bs_onb_sal_link' => 'Salaries',
'bs_onb_sal_desc' => 'Enter your income for the current year.',
'bs_onb_items_link' => 'Budget Items',
'bs_onb_items_desc' => 'Go to the Summary tab to list your fixed expenses (Rent, Insurance...).',
'bs_rule_search_ph' => '🔍 Search for a keyword (e.g. WALMART)...',
'error_loading_settings' => 'Error loading settings',
'settings' => 'Settings',
// --- BUDGET: TRACKING --- // --- BUDGET: TRACKING ---
'bud_rem_school' => 'School (estimated remaining)', 'bud_rem_school' => 'School (estimated remaining)',
@@ -482,6 +695,9 @@ return [
'bud_reg_none' => 'None', 'bud_reg_none' => 'None',
'bud_recap_confirm_delete' => 'Are you sure you want to delete this item?', 'bud_recap_confirm_delete' => 'Are you sure you want to delete this item?',
'bud_err_delete' => 'Error while deleting.', 'bud_err_delete' => 'Error while deleting.',
'bud_prev_monthly_target' => 'Target amount / Monthly payment (€)',
'bud_prev_transfer_dest' => 'Transfer destination',
'bud_prev_none' => 'No destination (Standard expense)',
// --- BUDGET: SAVINGS --- // --- BUDGET: SAVINGS ---
'bud_sav_add_month' => 'Enter a month', 'bud_sav_add_month' => 'Enter a month',
@@ -512,6 +728,39 @@ return [
'bud_sav_prompt_duplicate' => "Do you want to copy data from %s to %t1?\n\nEnter the new TOTAL bank balance (€) for %t2:", 'bud_sav_prompt_duplicate' => "Do you want to copy data from %s to %t1?\n\nEnter the new TOTAL bank balance (€) for %t2:",
'bud_err_server' => 'Server error: ', 'bud_err_server' => 'Server error: ',
'bud_err_network_dup' => 'Network error during duplication.', 'bud_err_network_dup' => 'Network error during duplication.',
'budget_tab_kids' => 'Kids 👶',
'bud_sav_add_one_month' => '+1 Month',
'bud_sav_add_month' => 'Add a month',
'bud_sav_no_data' => 'No savings data for %s.',
'bud_sav_post_month' => 'Item / Month',
'bud_sav_from_date' => 'From %s',
'bud_sav_edit_modal' => 'Edit this month',
'bud_sav_delete_month' => 'Delete this month',
'bud_sav_total_bank' => 'TOTAL BANK',
'bud_sav_extra' => 'UNALLOCATED (EXTRAS)',
'bud_sav_modal_title_add' => 'Add a month',
'bud_sav_modal_title_edit' => 'Edit',
'bud_sav_month_concerned' => 'Target month',
'bud_sav_total_bank_eur' => 'Total in Bank (€)',
'bud_sav_ventilation' => 'Accounts breakdown',
'bud_sav_adj_help' => 'Enter adjustments (+/-) to auto-calculate.',
'bud_sav_add_line' => 'Add line',
'bud_category' => 'Category',
'bud_sav_current' => 'Current',
'bud_sav_adjust' => 'Adjustment',
'bud_sav_new' => 'New',
'bud_sav_sum_mode_title' => 'Toggle calculator',
'bud_sav_selection' => 'Selection:',
'bud_sav_ph_name' => 'Account name',
// JS & Errors
'bud_sav_confirm_delete_month' => 'Are you sure you want to delete all data of %m for %o?',
'bud_sav_prompt_duplicate' => "Do you want to duplicate data from %s to %t1?\n\nEnter the new BANK TOTAL (€) for %t2:",
'bud_err_tech' => 'A technical error occurred.',
'bud_err_server' => 'Server error: ',
'bud_err_network_dup' => 'Network error during duplication.',
'bud_sav_saving' => 'Saving...',
'bud_err_delete' => 'Error during deletion.',
// --- BUDGET: FORECAST --- // --- BUDGET: FORECAST ---
'bud_prev_incomes' => 'Income', 'bud_prev_incomes' => 'Income',
@@ -548,50 +797,120 @@ return [
'bud_prev_confirm_copy' => "Do you want to copy data from %s to %t?\n\n⚠️ This will overwrite all values already present for %t.", 'bud_prev_confirm_copy' => "Do you want to copy data from %s to %t?\n\n⚠️ This will overwrite all values already present for %t.",
'bud_prev_confirm_transfers' => "Confirm that %p has made all their transfers for %m?\n\nThis will automatically update Savings.", 'bud_prev_confirm_transfers' => "Confirm that %p has made all their transfers for %m?\n\nThis will automatically update Savings.",
'bud_prev_label_name' => 'Name', 'bud_prev_label_name' => 'Name',
'bud_adv_title' => 'Advances & Family Tricount',
'bud_adv_add' => 'Add an Advance',
'bud_adv_edit' => 'Edit Advance',
'bud_adv_who_paid' => 'Who paid?',
'bud_adv_payer' => 'Payer',
'bud_adv_has_advanced' => '%s advanced:',
'bud_adv_cc_label' => 'on the joint account',
'bud_adv_livret_label' => 'from their savings account',
'bud_adv_cc_balance_title' => 'Joint Account Balance',
'bud_adv_owed_to' => '<strong>%s €</strong> are owed to <strong>%s</strong>',
'bud_adv_balanced' => 'Accounts are perfectly balanced!',
'bud_adv_saved_badge' => 'Savings',
'bud_adv_already_saved' => 'This money comes from a savings account',
'bud_adv_ph_desc' => 'e.g., Groceries, Electricity bill...',
'bud_adv_confirm_resolve' => 'Confirm the complete reimbursement of this advance?',
'bud_adv_confirm_delete' => 'Permanently delete this advance?',
'date' => 'Date',
'bud_label_name' => 'Description',
'bud_amount' => 'Amount',
'actions' => 'Actions',
'edit' => 'Edit',
'delete' => 'Delete',
'btn_cancel' => 'Cancel',
'btn_save' => 'Save',
'bud_prev_target_ph' => 'E.g., 150',
'bud_prev_transfer_to' => 'To',
// --- Module Budget : Provisions & Optimisation ---
'budget_provisions_title' => 'Provisions & Cashflow Optimization',
'btn_optimize_cashflow' => 'Optimize My Savings',
'add_new_provision' => 'Anticipate an upcoming major expense',
'provision_label' => 'Expense description',
'provision_placeholder_wood' => 'E.g., Firewood order, Car service...',
'amount' => 'Amount',
'expected_date' => 'Expected date',
'btn_add' => 'Add',
'upcoming_provisions_list' => 'Scheduled expenses',
'optimization_assistant_title' => 'Fortnightly Interest Optimization Assistant',
'error_generic' => 'An error occurred',
'success_add_provision' => 'Forecasted expense added successfully!',
'no_provisions' => 'No forecasted expenses registered at the moment.',
'btn_delete' => 'Delete',
'savings_this_month' => 'Total savings to allocate this month',
'calculate' => 'Calculate optimal strategy',
'date_past_warning' => 'The selected date has already passed!',
'budget_tab_provisions' => 'Forecasts',
// ========================================== // ==========================================
// GIFTS // MODULE: GIFTS (gift-list)
// ========================================== // ==========================================
'gift_page_title' => 'HouseHub - Gift list',
'gift_occ_tio' => 'Tió', // 1. Titles and headers
'gift_occ_noel' => 'Christmas', 'gift_page_title' => 'Gift List',
'gift_occ_rois' => 'Three Kings', 'gift_main_title' => 'Gifts of %s', // %s will be replaced by the year (e.g., Gifts of 2026)
'gift_occ_anniv' => 'Birthday', 'settings' => 'Settings',
'gift_occ_sant' => 'Name day',
'gift_main_title' => 'Gift list %s', // 2. Filters
'gift_aria_change_view' => 'Change view',
'gift_view_nadal' => 'Christmas',
'gift_view_anniv' => 'Birthdays',
'gift_view_by_party' => 'View by occasion',
'gift_no_gifts' => 'No gifts recorded for %s in this view.',
'gift_add_gift' => 'Add a gift',
'gift_paid_by' => 'paid by %s',
'gift_summary_title' => 'Budget summary',
'gift_col_adult' => 'Adult',
'gift_col_child' => 'Child',
'gift_col_party' => 'Occasion',
'gift_debtor' => 'Debtor',
'gift_creditor' => 'Creditor',
'gift_liquidations' => 'Settlement',
'gift_no_debt' => 'No outstanding debts.',
'gift_owes' => 'owes',
'gift_to' => 'to',
'gift_detailed_list' => 'Detailed gift list',
'gift_col_gift' => 'Gift',
'gift_col_link' => 'Link',
'gift_modal_title_add' => 'Add a gift for %s',
'gift_modal_title_edit' => 'Edit gift',
'gift_modal_payer' => 'Paid by',
'gift_modal_gift_name' => 'Gift name',
'gift_modal_ph_name' => 'e.g. Lego Star Wars',
'gift_modal_price' => 'Price (€)',
'gift_modal_link' => 'Link (optional)',
'gift_confirm_delete' => 'Are you sure you want to delete this gift?',
'gift_filter_all_children' => 'All children', 'gift_filter_all_children' => 'All children',
'gift_filter_all_adults' => 'All adults', 'gift_filter_all_adults' => 'All adults',
'gift_empty_state_no_gifts' => 'No gifts yet.',
'gift_empty_state_no_filter' => 'No gifts match the filter.', // 3. Content and empty states
'gift_add_gift' => 'Add',
'gift_empty_state_no_gifts' => 'No gifts recorded for this occasion.',
'gift_empty_state_no_filter' => 'No gifts match your filters.',
'gift_paid_by' => 'Paid by %s', // %s will be replaced by the payer's name
// 4. Tricount Section (Balance & Refunds)
'gift_liquidations' => 'Balance & Refunds',
'gift_no_debt' => 'Everything is settled, no debts!',
'gift_owes' => 'owes',
'gift_view_matrix' => 'View detailed matrix', 'gift_view_matrix' => 'View detailed matrix',
'gift_debtor' => 'Debtor',
'gift_creditor' => 'Creditor',
// 5. Modal: Add/Edit Gift
'gift_col_adult' => 'On behalf of',
'gift_modal_payer' => 'Paid by',
'gift_modal_gift_name' => 'Gift description',
'gift_modal_price' => 'Estimated / paid price (€)',
'gift_modal_link' => 'Product link (URL)',
'gift_modal_title_add' => 'Add for %s', // JS: %s = child's name
'gift_modal_title_edit' => 'Edit gift', // JS
'gift_confirm_delete' => 'Are you sure you want to delete this gift?', // JS
// 6. Modal: Occasions Settings
'gift_settings_title' => 'Occasions Settings',
'gift_settings_add_title' => '+ Add an occasion',
'gift_settings_name_placeholder' => 'Name (e.g. Valentine\'s Day)',
'gift_settings_date_placeholder' => 'MM-DD',
'gift_settings_date_help' => 'Date (MM-DD) is optional. Leave empty for a floating date.',
'gift_settings_empty' => 'No occasion configured.', // JS
'gift_settings_active' => 'Active', // JS
// 7. Generic buttons
'btn_cancel' => 'Cancel',
'btn_save' => 'Save',
'btn_add' => 'Add',
'loading' => 'Loading...',
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings' => 'Settings',
'gift_tab_occasions' => 'Occasions & Events',
'gift_tab_proches' => 'Relatives (Outside household)',
'gift_settings_icon' => 'Icon',
'gift_settings_date' => 'Date (MM-DD)',
'gift_settings_add_occ' => 'Add an occasion',
'gift_settings_add_proche'=> 'Add a relative',
'gift_role_proche_adulte'=> 'Adult (Uncle, friend...)',
'gift_role_proche_enfant'=> 'Child (Nephew, niece...)',
'gift_ph_proche_name' => 'First name',
'gift_role_label' => 'Role',
'gift_settings_saved' => 'Settings saved!',
'gift_settings_edit_occ' => 'Edit occasion',
'gift_settings_edit_proche' => 'Edit relative',
// ========================================== // ==========================================
// GARAGE MANAGER // GARAGE MANAGER
+365 -38
View File
@@ -156,6 +156,20 @@ return [
'mod_planka_desc' => 'Gérez vos projets en mode Kanban avec Planka.', 'mod_planka_desc' => 'Gérez vos projets en mode Kanban avec Planka.',
'menu_planka' => 'Planka', 'menu_planka' => 'Planka',
// --- SETTINGS : ENFANTS ---
'set_kids_title' => 'Enfants du foyer',
'set_kids_desc' => 'Gérez la liste des enfants pour les associer à l\'épargne, aux cadeaux, etc.',
'set_add_kid_placeholder' => 'Prénom de l\'enfant',
'set_btn_add_kid' => 'Ajouter',
'set_err_kid_name' => 'Le prénom est requis.',
'set_confirm_del_kid' => 'Supprimer cet enfant ? (Son historique financier restera visible dans les archives).',
'set_no_kids' => 'Aucun enfant configuré pour ce foyer.',
'set_family_config' => 'Configuration du foyer',
'set_global_params' => 'Paramètres globaux',
'set_care_modes' => 'Modes de garde usuels',
'set_add_care_mode' => 'Ajouter un mode',
'btn_save_config' => 'Enregistrer',
// ========================================== // ==========================================
// FAMILY CALENDAR // FAMILY CALENDAR
// ========================================== // ==========================================
@@ -199,6 +213,126 @@ return [
'fc_err_action' => 'Erreur lors de l\'action : ', 'fc_err_action' => 'Erreur lors de l\'action : ',
'fc_today_short' => 'Auj.', 'fc_today_short' => 'Auj.',
'fc_today_title' => "Aujourd'hui", 'fc_today_title' => "Aujourd'hui",
'care_nounou' => 'Nounou',
'care_centre' => 'Centre de loisirs',
'care_avis' => 'Avis',
'fc_tab_foyer' => '🏡 Foyer',
'fc_tab_members' => '👥 Membres & Droits',
'fc_select_member' => 'Sélectionner un membre de la famille :',
'fc_role_adult' => 'Adulte',
'fc_role_child' => 'Enfant',
'fc_care_modes_title' => 'Modes de garde associés',
'fc_care_modes_desc' => 'Cochez les modes de garde qui concernent cet enfant.',
'fc_no_care_mode' => 'Aucun mode configuré.',
'fc_matrix_title' => '👨‍👩‍👦 Matrice des Droits aux Congés',
'fc_matrix_desc' => 'Déclarez les codes de compteurs et dates de renouvellement.',
'fc_matrix_code' => 'Code',
'fc_matrix_date' => 'Date Reset',
'fc_matrix_empty' => 'Aucun compteur configuré.',
'fc_matrix_saved' => 'Matrice des droits mise à jour ! 💼',
'fc_child_saved' => 'Abonnement enfant enregistré !',
'btn_add_counter' => ' Ajouter',
'btn_save_rights' => '💾 Enregistrer',
'fc_modal_settings_title' => 'Paramètres du Calendrier',
'fc_zone_label' => 'Zone Scolaire (Vacances)',
'fc_zone_desc' => 'Détermine le calendrier automatique des vacances scolaires.',
'fc_care_modes_label' => 'Modes de Garde du Foyer',
'fc_care_modes_desc' => 'Gérez la liste globale des modes de garde possibles pour la famille.',
'fc_add_mode_placeholder' => 'Ex: Nounou, Casal, Grands-parents...',
'fc_settings_updated' => 'Paramètres mis à jour avec succès !',
'fc_first_day' => 'Premier jour de la semaine',
// --- CALENDRIER : PARAMÈTRES (MEMBRES & AFFICHAGE) ---
'fc_tab_display' => '🖥️ Affichage',
'fc_display_title' => 'Paramètres d\'affichage',
'fc_display_desc' => 'Personnalisez l\'apparence et le comportement par défaut de votre calendrier.',
'fc_default_view' => 'Vue par défaut',
'fc_view_month' => '1 mois',
'fc_view_2months' => '2 mois',
'fc_view_3months' => '3 mois',
'fc_working_hours' => 'Horaires de garde (Défaut)',
'fc_working_hours_desc' => 'Exemple : 08:30-18:00',
'fc_role_unknown' => 'Inconnu',
'fc_role_adult' => 'Adulte',
'fc_role_child' => 'Enfant',
'fc_role_helper' => 'Intervenant',
'fc_error' => 'Erreur : ',
'fc_care_modes_title' => 'Modes de Garde Par Défaut',
'fc_care_modes_desc' => 'Quels modes de garde s\'appliquent à cet enfant ?',
'fc_no_care_mode' => 'Aucun mode de garde configuré dans le foyer.',
'fc_child_saved' => 'Sauvegardé !',
'fc_matrix_title' => 'Matrice des Congés',
'fc_matrix_desc' => 'Définissez les quotas et la date de renouvellement.',
'fc_matrix_empty' => 'Aucun compteur défini.',
'fc_matrix_saved' => 'Matrice enregistrée !',
'fc_leave_obsolete' => 'Obsolète',
'fc_leave_code_placeholder' => 'Code...',
'fc_leave_method_fixed' => 'Fixe',
'fc_leave_method_accumulated' => 'Cumul',
'fc_date_format_placeholder' => 'JJ/MM',
'fc_date_format_title' => 'Format JJ/MM',
'fc_table_code' => 'Code',
'fc_table_method' => 'Méthode',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renouv.',
'btn_save_rights' => 'Enregistrer',
// ==========================================
// MODALE PARAMÈTRES CALENDRIER
// ==========================================
'fc_modal_settings_title' => 'Paramètres du calendrier',
'fc_tab_foyer' => 'Foyer',
'fc_tab_members' => 'Membres & Droits',
// Foyer : Zone scolaire
'fc_zone_label' => 'Zone scolaire',
'fc_zone_a' => 'Zone A',
'fc_zone_b' => 'Zone B',
'fc_zone_c' => 'Zone C',
'fc_zone_other' => 'Autre / Hors France',
'fc_zone_desc' => 'Permet d\'afficher correctement les vacances scolaires.',
// Foyer : Modes de garde
'fc_care_modes_label' => 'Modes de garde',
'fc_add_mode_placeholder' => 'Ex: Nounou, Crèche...',
'fc_care_modes_desc' => 'Ajoutez les différents modes de garde pour vos enfants.',
'fc_empty_kids_msg' => 'Aucun enfant n\'est configuré dans votre famille pour le moment.',
'fc_link_settings' => 'Aller aux paramètres généraux',
// Foyer : Catalogue des congés
'fc_leave_catalog_title' => 'Catalogue des congés',
'fc_leave_catalog_desc' => 'Définissez les types de congés possibles pour toute la famille.',
'fc_add_leave_type' => 'Ajouter un type de congé',
'fc_col_code' => 'Code',
'fc_code_irreversible' => 'Irréversible',
'fc_col_label' => 'Label',
'fc_leave_label_ph' => 'Ex: RTT, Récup...',
'fc_quota_default' => 'Quota par défaut',
'fc_reset_month' => 'Mois de renouv.',
// Membres
'fc_select_member' => 'Sélectionner un membre :',
// Mois (S'ils n'existent pas déjà dans ton système)
'month_jan' => 'Janvier',
'month_feb' => 'Février',
'month_mar' => 'Mars',
'month_apr' => 'Avril',
'month_may' => 'Mai',
'month_jun' => 'Juin',
'month_jul' => 'Juillet',
'month_aug' => 'Août',
'month_sep' => 'Septembre',
'month_oct' => 'Octobre',
'month_nov' => 'Novembre',
'month_dec' => 'Décembre',
'leg_presence' => 'Présence Pep', 'leg_presence' => 'Présence Pep',
'leg_school_holidays' => 'Vacances Scolaires', 'leg_school_holidays' => 'Vacances Scolaires',
@@ -385,6 +519,71 @@ return [
'cat_fixed' => 'Charges Fixes', 'cat_fixed' => 'Charges Fixes',
'cat_others' => 'Autres / Imprévus', 'cat_others' => 'Autres / Imprévus',
'cat_savings' => 'Épargne', 'cat_savings' => 'Épargne',
'budget_tab_kids' => 'Enfants',
'budget_settings_title' => 'Paramètres du Budget',
'bs_tab_accounts' => '💳 Comptes',
'bs_tab_categories' => '🏷️ Catégories',
'bs_tab_rules' => '🤖 Règles d\'import',
'bs_tab_salaries' => '💼 Salaires',
'bs_empty_accounts' => 'Aucun compte configuré.',
'bs_type_checking' => 'Courant',
'bs_type_savings' => 'Épargne',
'bs_badge_default' => 'Défaut',
'bs_owner' => 'Propriétaire',
'bs_shared_account' => 'Compte Commun',
'bs_empty_categories' => 'Aucune catégorie configurée.',
'bs_type_income' => 'Revenu',
'bs_type_expense' => 'Dépense',
'bs_type_savings_cat' => 'Épargne',
'bs_empty_rules' => 'Aucune règle d\'importation.',
'bs_empty_salaries' => 'Aucun salaire défini pour l\'année',
'bs_salaries_year_desc' => 'Configurations pour l\'année',
'bs_salary_net' => 'Salaire net',
'bs_salary_fees' => 'Mensualité / Frais',
'bs_add_account_title' => '+ Ajouter un compte',
'bs_account_name_ph' => 'Nom (ex: Livret A Bébé)',
'bs_confirm_delete' => 'Voulez-vous vraiment supprimer ce compte ?',
'bs_add_category_title' => '+ Ajouter une catégorie',
'bs_cat_code_ph' => 'Code (ex: FOOD)',
'bs_cat_label_ph' => 'Libellé (ex: Courses)',
'bs_cat_icon_ph' => 'Icône (ex: 🛒)',
'bs_confirm_delete_cat' => 'Voulez-vous vraiment supprimer cette catégorie ?',
'bs_add_rule_title' => 'Ajouter une règle',
'bs_rule_keyword_ph' => 'Mot-clé (ex: AUCHAN)',
'bs_confirm_delete_rule' => 'Voulez-vous vraiment supprimer cette règle ?',
'bs_currency_label' => 'Devise du Foyer',
'bs_currency_desc' => 'Devise principale utilisée pour l\'affichage de vos comptes et budgets.',
'bs_currency_updated' => 'Devise mise à jour avec succès !',
// ONGLET CSV MAPPING
'bs_csv_title' => 'Format de l\'import CSV',
'bs_csv_desc' => 'Configurez ici les colonnes correspondantes à l\'export de votre banque.',
'bs_csv_delimiter' => 'Séparateur',
'bs_csv_delim_semi' => 'Point-virgule (;)',
'bs_csv_delim_comma' => 'Virgule (,)',
'bs_csv_delim_tab' => 'Tabulation',
'bs_csv_date_format' => 'Format de la Date',
'bs_csv_col_index' => 'Index des colonnes',
'bs_csv_col_index_help' => '(Attention : la 1ère colonne est la Numéro 0)',
'bs_csv_col_date' => 'N° Col : Date',
'bs_csv_col_label' => 'N° Col : Libellé / Motif',
'bs_csv_amount_mgmt' => 'Gestion des montants',
'bs_csv_amount_single' => 'Une seule colonne (Positif = Revenu, Négatif = Dépense)',
'bs_csv_amount_split' => 'Deux colonnes séparées (Débit / Crédit)',
'bs_csv_col_amount' => 'N° Col : Montant',
'bs_csv_col_debit' => 'N° Col : Débit',
'bs_csv_col_credit' => 'N° Col : Crédit',
'bs_csv_col_ref' => 'N° Col : Référence unique (Optionnel)',
'btn_save_format' => 'Enregistrer le format',
'bs_csv_saved' => 'Format CSV enregistré',
'bs_tab_csv' => '📥 Import CSV',
'bs_csv_settings_title' => 'Configuration du format',
'bs_csv_drop_title' => 'Glissez votre fichier CSV ici',
'bs_csv_drop_desc' => 'ou cliquez pour parcourir vos fichiers',
'bs_csv_err_type' => 'Erreur : Seuls les fichiers .csv sont autorisés.',
'bs_csv_file_ok' => 'Fichier chargé',
'bs_csv_preview_title' => 'Aperçu de vos colonnes',
'bs_csv_empty_col' => 'Vide',
'bs_csv_no_data' => 'Aucune ligne de transaction trouvée dans ce fichier.',
// --- BUDGET : SUIVI --- // --- BUDGET : SUIVI ---
'bud_rem_school' => 'École (Reste estimé)', 'bud_rem_school' => 'École (Reste estimé)',
@@ -453,6 +652,25 @@ return [
'bud_to_define' => "À définir", 'bud_to_define' => "À définir",
'bud_is_charge' => "Est une charge fixe...", 'bud_is_charge' => "Est une charge fixe...",
'bud_is_income' => "Est un revenu...", 'bud_is_income' => "Est un revenu...",
'bud_edit_title' => 'Modifier la transaction',
'bud_to_define_js' => 'à définir',
'bud_select_beneficiary' => 'Choisir un revenu attendu',
'bud_bar_actual' => 'Actuel',
// ONBOARDING BUDGET
'bs_onb_title' => 'Bienvenue ! Configurons votre budget :',
'bs_onb_desc' => 'Complétez ces étapes pour que l\'application puisse calculer votre Reste à Vivre :',
'bs_onb_cat_link' => 'Catégories',
'bs_onb_cat_desc' => 'Créez au moins une catégorie de dépenses.',
'bs_onb_acc_link' => 'Comptes Bancaires',
'bs_onb_acc_desc' => 'Ajoutez au moins un compte courant ou épargne.',
'bs_onb_sal_link' => 'Salaires',
'bs_onb_sal_desc' => 'Renseignez les rentrées d\'argent de l\'année en cours.',
'bs_onb_items_link' => 'Lignes de Budget',
'bs_onb_items_desc' => 'Rendez-vous dans l\'onglet Récapitulatif pour lister vos charges fixes (Loyer, Assurances...).',
'bs_rule_search_ph' => '🔍 Rechercher un mot-clé (ex: AUCHAN)...',
'error_loading_settings' => 'Erreur lors du chargement des paramètres',
'settings' => 'Paramètres',
// --- BUDGET : RECAP --- // --- BUDGET : RECAP ---
'bud_recap_monthly_title' => 'Récapitulatif Mensuel', 'bud_recap_monthly_title' => 'Récapitulatif Mensuel',
@@ -495,6 +713,10 @@ return [
'bud_reg_none' => 'Aucune', 'bud_reg_none' => 'Aucune',
'bud_recap_confirm_delete' => 'Voulez-vous vraiment supprimer cet élément ?', 'bud_recap_confirm_delete' => 'Voulez-vous vraiment supprimer cet élément ?',
'bud_err_delete' => 'Erreur lors de la suppression.', 'bud_err_delete' => 'Erreur lors de la suppression.',
'bud_prev_monthly_target' => 'Montant cible / Mensualité (€)',
'bud_prev_transfer_dest' => 'Destination du virement',
'bud_prev_none' => 'Aucune destination (Charge standard)',
// --- BUDGET : EPARGNE --- // --- BUDGET : EPARGNE ---
'bud_sav_add_month' => 'Saisir un mois', 'bud_sav_add_month' => 'Saisir un mois',
@@ -525,6 +747,68 @@ return [
'bud_sav_prompt_duplicate' => "Voulez-vous copier les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :", 'bud_sav_prompt_duplicate' => "Voulez-vous copier les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
'bud_err_server' => 'Erreur serveur : ', 'bud_err_server' => 'Erreur serveur : ',
'bud_err_network_dup' => 'Erreur réseau lors de la duplication.', 'bud_err_network_dup' => 'Erreur réseau lors de la duplication.',
'budget_tab_kids' => 'Enfants 👶',
'bud_sav_add_one_month' => '+1 Mois',
'bud_sav_add_month' => 'Ajouter un mois',
'bud_sav_no_data' => 'Aucune donnée d\'épargne pour %s.',
'bud_sav_post_month' => 'Poste / Mois',
'bud_sav_from_date' => 'Dès le %s',
'bud_sav_edit_modal' => 'Éditer ce mois',
'bud_sav_delete_month' => 'Supprimer ce mois',
'bud_sav_total_bank' => 'TOTAL BANQUE',
'bud_sav_extra' => 'NON ALLOUÉ (EXTRAS)',
'bud_sav_modal_title_add' => 'Ajouter un mois',
'bud_sav_modal_title_edit' => 'Éditer',
'bud_sav_month_concerned' => 'Mois concerné',
'bud_sav_total_bank_eur' => 'Total en Banque (€)',
'bud_sav_ventilation' => 'Ventilation des comptes',
'bud_sav_adj_help' => 'Saisissez les ajustements (+/-) pour recalculer automatiquement.',
'bud_sav_add_line' => 'Ajouter une ligne',
'bud_category' => 'Catégorie',
'bud_sav_current' => 'Actuel',
'bud_sav_adjust' => 'Ajustement',
'bud_sav_new' => 'Nouveau',
'bud_sav_sum_mode_title' => 'Activer/Désactiver la calculatrice',
'bud_sav_selection' => 'Sélection :',
'bud_sav_ph_name' => 'Nom du compte',
// --- BUDGET : PREVISION ---
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
'btn_optimize_cashflow' => 'Optimiser mes Économies',
'add_new_provision' => 'Anticiper une grosse dépense à venir',
'provision_label' => 'Libellé de la dépense',
'provision_placeholder_wood' => 'Ex: Commande bois de chauffage, Révision voiture...',
'amount' => 'Montant',
'expected_date' => 'Date prévue',
'btn_add' => 'Ajouter',
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
'error_generic' => 'Une erreur est survenue',
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
'btn_delete' => 'Supprimer',
'savings_this_month' => 'Économies globales à placer ce mois-ci',
'calculate' => 'Calculer la stratégie optimale',
'date_past_warning' => 'La date sélectionnée est déjà passée !',
'budget_tab_provisions' => 'Provisions',
'budget_opti_base' => 'Base',
'budget_opti_debt' => 'Dette compensée',
'budget_opti_no_config' => 'Aucune configuration de salaire trouvée pour cette année.',
'budget_opti_recap_apports' => 'Résumé des apports (Clearing) :',
'budget_opti_total_disp' => 'Total disponible pour l\'optimisation :',
'budget_opti_must_pay' => 'doit verser',
// JS & Erreurs
'bud_sav_confirm_delete_month' => 'Voulez-vous vraiment supprimer toutes les données de %m pour %o ?',
'bud_sav_prompt_duplicate' => "Voulez-vous dupliquer les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
'bud_err_tech' => 'Une erreur technique est survenue.',
'bud_err_server' => 'Erreur serveur : ',
'bud_err_network_dup' => 'Erreur réseau lors de la duplication.',
'bud_sav_saving' => 'Sauvegarde en cours...',
'bud_err_delete' => 'Erreur lors de la suppression.',
// --- BUDGET : PREVISIONNEL --- // --- BUDGET : PREVISIONNEL ---
'bud_prev_incomes' => 'Revenus', 'bud_prev_incomes' => 'Revenus',
@@ -563,50 +847,93 @@ return [
'bud_sav_modal_title_add' => 'Saisir un mois', 'bud_sav_modal_title_add' => 'Saisir un mois',
'bud_sav_ph_name' => 'Nom (ex: Vacances)', 'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_prev_label_name' => 'Nom', 'bud_prev_label_name' => 'Nom',
'bud_adv_title' => 'Avances & Tricount Familial',
'bud_adv_add' => 'Ajouter une avance',
'bud_adv_edit' => 'Modifier l\'avance',
'bud_adv_who_paid' => 'Qui a payé ?',
'bud_adv_payer' => 'Payeur',
'bud_adv_has_advanced' => '%s a avancé :',
'bud_adv_cc_label' => 'sur le compte commun',
'bud_adv_livret_label' => 'depuis son livret d\'épargne',
'bud_adv_cc_balance_title' => 'Équilibre du Compte Commun',
'bud_adv_owed_to' => '<strong>%s €</strong> sont dus à <strong>%s</strong>',
'bud_adv_balanced' => 'Les comptes sont parfaitement équilibrés !',
'bud_adv_saved_badge' => 'Épargne',
'bud_adv_already_saved' => 'Cet argent provient d\'un livret d\'épargne',
'bud_adv_ph_desc' => 'Ex: Courses, Facture Électricité...',
'bud_adv_confirm_resolve' => 'Confirmer le remboursement complet de cette avance ?',
'bud_adv_confirm_delete' => 'Supprimer définitivement cette avance ?',
'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_transfer_to' => 'Vers',
// ========================================== // ==========================================
// CADEAUX (GIFTS) // MODULE : CADEAUX (gift-list)
// ========================================== // ==========================================
'gift_page_title' => 'HouseHub - Liste de cadeaux',
'gift_occ_tio' => 'Tió', // 1. Titres et en-têtes
'gift_occ_noel' => 'Noël', 'gift_page_title' => 'Liste des Cadeaux',
'gift_occ_rois' => 'Rois mages', 'gift_main_title' => 'Cadeaux de %s', // %s sera remplacé par l'année (ex: Cadeaux de 2026)
'gift_occ_anniv' => 'Anniversaire', 'settings' => 'Paramètres',
'gift_occ_sant' => 'Saint',
'gift_main_title' => 'Liste de cadeaux %s', // 2. Filtres
'gift_aria_change_view' => 'Changer la vue',
'gift_view_nadal' => 'Noël',
'gift_view_anniv' => 'Anniversaires',
'gift_view_by_party' => 'Vue par fête',
'gift_no_gifts' => 'Aucun cadeau enregistré pour %s dans cette vue.',
'gift_add_gift' => 'Ajouter un cadeau',
'gift_paid_by' => 'payé par %s',
'gift_summary_title' => 'Résumé du budget',
'gift_col_adult' => 'Adulte',
'gift_col_child' => 'Enfant',
'gift_col_party' => 'Fête',
'gift_debtor' => 'Débiteur',
'gift_creditor' => 'Créancier',
'gift_liquidations' => 'Tricount',
'gift_no_debt' => 'Aucune dette en cours.',
'gift_owes' => 'doit',
'gift_to' => 'à',
'gift_detailed_list' => 'Liste détaillée des cadeaux',
'gift_col_gift' => 'Cadeau',
'gift_col_link' => 'Lien',
'gift_modal_title_add' => 'Ajouter un cadeau pour %s',
'gift_modal_title_edit' => 'Modifier le cadeau',
'gift_modal_payer' => 'Payé par',
'gift_modal_gift_name' => 'Nom du cadeau',
'gift_modal_ph_name' => 'ex: Lego Star Wars',
'gift_modal_price' => 'Prix (€)',
'gift_modal_link' => 'Lien (optionnel)',
'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?',
'gift_filter_all_children' => 'Tous les enfants', 'gift_filter_all_children' => 'Tous les enfants',
'gift_filter_all_adults' => 'Tous les adultes', 'gift_filter_all_adults' => 'Tous les adultes',
'gift_empty_state_no_gifts' => 'Aucun cadeau pour le moment.',
'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond au filtre.', // 3. Contenu et états vides
'gift_add_gift' => 'Ajouter',
'gift_empty_state_no_gifts' => 'Aucun cadeau enregistré pour cette fête.',
'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond à vos filtres.',
'gift_paid_by' => 'Payé par %s', // %s sera remplacé par le nom du payeur
// 4. Section Tricount (Bilan & Remboursements)
'gift_liquidations' => 'Bilan & Remboursements',
'gift_no_debt' => 'Tout est à jour, aucune dette !',
'gift_owes' => 'doit',
'gift_view_matrix' => 'Voir la matrice détaillée', 'gift_view_matrix' => 'Voir la matrice détaillée',
'gift_debtor' => 'Débiteur',
'gift_creditor' => 'Créancier',
// 5. Modale : Ajout/Modification de Cadeau
'gift_col_adult' => 'Pour le compte de',
'gift_modal_payer' => 'Payé par',
'gift_modal_gift_name' => 'Description du cadeau',
'gift_modal_price' => 'Prix estimé / payé (€)',
'gift_modal_link' => 'Lien du produit (URL)',
'gift_modal_title_add' => 'Ajouter pour %s', // JS : %s = nom de l'enfant
'gift_modal_title_edit' => 'Modifier le cadeau', // JS
'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?', // JS
// 6. Modale : Paramètres des Fêtes (Nouveau !)
'gift_settings_title' => 'Configuration des Fêtes',
'gift_settings_add_title' => '+ Ajouter une occasion',
'gift_settings_name_placeholder' => 'Nom (ex: Saint Valentin)',
'gift_settings_date_placeholder' => 'MM-JJ',
'gift_settings_date_help' => 'La date (MM-JJ) est optionnelle. Laissez vide pour une fête mobile.',
'gift_settings_empty' => 'Aucune fête configurée.', // JS
'gift_settings_active' => 'Actif', // JS
// 7. Boutons génériques (si tu ne les as pas déjà en global)
'btn_cancel' => 'Annuler',
'btn_save' => 'Enregistrer',
'btn_add' => 'Ajouter',
'loading' => 'Chargement...',
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings' => 'Configuration',
'gift_tab_occasions' => 'Fêtes & Occasions',
'gift_tab_proches' => 'Proches (Hors foyer)',
'gift_settings_icon' => 'Icône',
'gift_settings_date' => 'Date (MM-JJ)',
'gift_settings_add_occ' => 'Ajouter une fête',
'gift_settings_add_proche'=> 'Ajouter un proche',
'gift_role_proche_adulte'=> 'Adulte (Oncle, ami...)',
'gift_role_proche_enfant'=> 'Enfant (Neveu, nièce...)',
'gift_ph_proche_name' => 'Prénom',
'gift_role_label' => 'Rôle',
'gift_settings_saved' => 'Paramètres enregistrés !',
'gift_settings_edit_occ' => 'Modifier la fête',
'gift_settings_edit_proche' => 'Modifier le proche',
// ========================================== // ==========================================
// GARAGE MANAGER // GARAGE MANAGER
// ========================================== // ==========================================
+19 -3
View File
@@ -1,9 +1,23 @@
<?php <?php
// includes/meta_db.php
// Connexion à la base meta (gestion des familles et utilisateurs) // Connexion à la base meta (gestion des familles et utilisateurs)
$meta_host = getenv('DB_HOST') ?: 'househub-db';
$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'; $meta_db = 'househub_meta';
$meta_user = getenv('DB_USER') ?: 'househub';
$meta_pass = getenv('DB_PASS') ?: 'changeme';
try { try {
$meta_pdo = new PDO( $meta_pdo = new PDO(
@@ -14,8 +28,10 @@ try {
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
] ]
); );
} catch (\PDOException $e) { } catch (\PDOException $e) {
die(json_encode(['error' => 'Meta DB unavailable: ' . $e->getMessage()])); die(json_encode(['error' => 'Meta DB unavailable: ' . $e->getMessage()]));
} }
?>
-8
View File
@@ -18,14 +18,6 @@ require __DIR__ . '/header.php';
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button> <button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
</div> </div>
<!-- Formulaire de création de liste inline -->
<div class="liste-new-form hidden" id="liste-new-form">
<input type="text" id="new-list-name" maxlength="100"
placeholder="<?= htmlspecialchars(tr('liste_list_name_placeholder')) ?>" autocomplete="off">
<button id="btn-confirm-new-list"><?= htmlspecialchars(tr('liste_create')) ?></button>
<button id="btn-cancel-new-list"><?= htmlspecialchars(tr('cancel')) ?></button>
</div>
<!-- Corps de la liste active --> <!-- Corps de la liste active -->
<div class="liste-body" id="liste-body"> <div class="liste-body" id="liste-body">
+30 -28
View File
@@ -1,46 +1,48 @@
<?php <?php
// Script de migration pour mettre à jour TOUTES les bases de données familiales
require_once __DIR__ . '/includes/meta_db.php'; require_once __DIR__ . '/includes/meta_db.php';
echo "<h1>🚀 Début de la migration...</h1>"; // Récupération des identifiants de BDD depuis l'environnement (fallback par défaut)
$db_host = getenv('DB_HOST') ?: 'househub-db';
$db_user = getenv('DB_USER') ?: 'househub';
$db_pass = getenv('DB_PASS') ?: 'changeme';
// 1. Récupérer toutes les bases de données des familles echo "<h1>🛠️ HouseHub - Correction AUTO_INCREMENT Voyages</h1>";
$stmt = $meta_pdo->query("SELECT name, db_name FROM families WHERE db_name != ''");
$families = $stmt->fetchAll();
$host = getenv('DB_HOST') ?: 'househub-db'; try {
$user = getenv('DB_USER') ?: 'househub'; // On récupère toutes les familles actives
$pass = getenv('DB_PASS') ?: 'changeme'; $stmt = $meta_pdo->query("SELECT db_name, name FROM families");
$families = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 2. Boucler sur chaque famille foreach ($families as $f) {
foreach ($families as $f) { $dbName = $f['db_name'];
$db_name = $f['db_name']; echo "<h3>Famille : {$f['name']} ($dbName)</h3><ul>";
echo "Mise à jour de la famille <strong>{$f['name']}</strong> (Base : $db_name) ... ";
try { try {
$fam_pdo = new PDO("mysql:host=$host;dbname=$db_name;charset=utf8mb4", $user, $pass, [ $pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]); ]);
// 3. Injecter la nouvelle table // 1. On vérifie si la clé primaire existe déjà pour éviter de faire planter le script
$sql = " $hasPrimaryKey = $pdo->query("SHOW KEYS FROM pf_holidays WHERE Key_name = 'PRIMARY'")->fetch();
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT '€',
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '€', 'C'); if (!$hasPrimaryKey) {
"; $pdo->exec("ALTER TABLE pf_holidays ADD PRIMARY KEY (id)");
echo "<li>✅ Clé primaire restaurée sur la colonne `id`.</li>";
}
$fam_pdo->exec($sql); // 2. On restaure la propriété AUTO_INCREMENT
echo "<span style='color:green'>✅ OK</span><br>"; $pdo->exec("ALTER TABLE pf_holidays MODIFY id INT(11) NOT NULL AUTO_INCREMENT");
echo "<li>✅ Propriété AUTO_INCREMENT restaurée.</li>";
} catch (\PDOException $e) { } catch (\PDOException $e) {
echo "<span style='color:red'>❌ Erreur : " . $e->getMessage() . "</span><br>"; // On attrape l'erreur si la modif a déjà été faite
echo "<li>️ Action ignorée ou table déjà à jour : " . $e->getMessage() . "</li>";
} }
} echo "</ul>";
}
echo "<h2>🚀 Correction terminée ! Tu peux réessayer de créer un voyage.</h2>";
echo "<h2>🎉 Migration terminée ! Tu peux supprimer ce fichier.</h2>"; } catch (Exception $e) {
die("Erreur fatale de connexion à la Meta DB : " . $e->getMessage());
}
?> ?>
+49
View File
@@ -0,0 +1,49 @@
<?php
// migrate_attachments.php
// 1. Inclusion de ta connexion BDD principale (Ajuste le chemin si besoin)
require __DIR__ . '/includes/db.php';
echo "<pre>🚀 Lancement de la migration multi-bases...\n\n";
try {
// 2. Récupérer toutes les bases de données qui commencent par "househub_f"
$stmt = $pdo->query("SHOW DATABASES LIKE 'househub_f%'");
$databases = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($databases)) {
die("❌ Aucune base de données familiale trouvée (Format househub_f1, househub_f2...).\n");
}
$successCount = 0;
foreach ($databases as $dbName) {
// Sécurité : on vérifie le format exact (househub_f suivi de chiffres)
if (preg_match('/^househub_f\d+$/', $dbName)) {
echo "⚙️ Mise à jour de la base : <strong>$dbName</strong>... ";
// On bascule la connexion sur la base de la famille
$pdo->exec("USE `$dbName`");
// 3. Création de la table
$sql = "CREATE TABLE IF NOT EXISTS pf_holidays_attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
holiday_id INT NOT NULL,
item_id INT DEFAULT NULL,
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(255) NOT NULL,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_holiday_attachment FOREIGN KEY (holiday_id) REFERENCES pf_holidays(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$pdo->exec($sql);
echo "✅ OK\n";
$successCount++;
}
}
echo "\n🎉 Migration terminée avec succès sur $successCount base(s) familiale(s) !</pre>";
} catch (PDOException $e) {
die("\n❌ Erreur SQL pendant la migration : " . $e->getMessage() . "</pre>");
}
+64
View File
@@ -0,0 +1,64 @@
<?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>";
}
?>
+922 -70
View File
File diff suppressed because it is too large Load Diff
@@ -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()]);
}
+51 -35
View File
@@ -6,30 +6,25 @@ require_login();
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
// --- ACTION : SAUVEGARDER (AJOUT OU MODIF) --- // --- ACTION : SAUVEGARDER (AJOUT OU MODIF DU BUDGET) ---
if ($action === 'save') { if ($action === 'save') {
$id = $_POST['id'] ?? ''; try {
$name = $_POST['name']; // 1. Sécurisation des données entrantes
$category = $_POST['category']; $id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$type = $_POST['type']; $name = trim($_POST['name'] ?? '');
$payment_day = empty($_POST['payment_day']) ? null : (int)$_POST['payment_day']; $category = $_POST['category'] ?? '';
$is_estimate = $_POST['is_estimate']; $type = $_POST['type'] ?? 'Mensuel';
$reg_month = $_POST['reg_month']; $payment_day = !empty($_POST['payment_day']) ? (int)$_POST['payment_day'] : null;
$keywords = $_POST['mapping_keywords'] ?? ''; $is_estimate = isset($_POST['is_estimate']) ? (int)$_POST['is_estimate'] : 0;
$reg_month = !empty($_POST['reg_month']) ? trim($_POST['reg_month']) : null;
$keywords = trim($_POST['mapping_keywords'] ?? '');
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; $holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
// ========================================== // 2. Gestion du montant (toujours stocker les dépenses en négatif)
// NOUVELLE NORME COMPTABLE : Dépense = Négatif $amount = abs((float)($_POST['amount'] ?? 0));
// ========================================== $amount = -$amount; // On force le négatif car le modal Recap ne gère que des charges
// On récupère le montant en valeur absolue (pour éviter les erreurs si l'utilisateur a tapé un '-')
$amount = abs((float)$_POST['amount']);
// Si c'est un frais, on l'enregistre en négatif
if ($category === 'expense') {
$amount = -$amount;
}
// ==========================================
// 3. Exécution de la requête
if ($id) { if ($id) {
// UPDATE // UPDATE
$stmt = $pdo->prepare("UPDATE pf_budget_items SET name=?, amount=?, category=?, type=?, payment_day=?, is_estimate=?, reg_month=?, mapping_keywords=?, holiday_id=? WHERE id=?"); $stmt = $pdo->prepare("UPDATE pf_budget_items SET name=?, amount=?, category=?, type=?, payment_day=?, is_estimate=?, reg_month=?, mapping_keywords=?, holiday_id=? WHERE id=?");
@@ -40,11 +35,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id]); $stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id]);
} }
// Redirection // 4. Réponse appropriée (JSON si appel via JS fetch, Redirection sinon)
if (!empty($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
} else {
header('Location: /budget.php?tab=recap'); header('Location: /budget.php?tab=recap');
exit; exit;
} }
} catch (PDOException $e) {
// En cas d'erreur BDD (ex: contrainte de clé étrangère, colonne manquante)
if (!empty($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]);
exit;
}
die("Erreur fatale : " . $e->getMessage());
}
}
// --- ACTION : COCHER/DÉCOCHER RAPIDE (VIA JS FETCH) --- // --- ACTION : COCHER/DÉCOCHER RAPIDE (VIA JS FETCH) ---
if ($action === 'toggle-check') { if ($action === 'toggle-check') {
$id = $_POST['id']; $id = $_POST['id'];
@@ -59,7 +70,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit; exit;
} }
// --- ACTION : SUPPRIMER --- // --- ACTION : SUPPRIMER UNE LIGNE DU BUDGET ---
if ($action === 'delete') { if ($action === 'delete') {
$id = $_POST['id']; $id = $_POST['id'];
$pdo->prepare("DELETE FROM pf_budget_items WHERE id = ?")->execute([$id]); $pdo->prepare("DELETE FROM pf_budget_items WHERE id = ?")->execute([$id]);
@@ -84,7 +95,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// --- ACTION : SAUVEGARDER UNE DÉPENSE MANUELLE (FETCH) --- // --- ACTION : SAUVEGARDER UNE DÉPENSE MANUELLE (FETCH) ---
if ($action === 'save_expense_manual') { if ($action === 'save_expense_manual') {
header('Content-Type: application/json'); // On force le retour JSON pour notre script JS header('Content-Type: application/json');
try { try {
$id = !empty($_POST['expense_id']) ? (int)$_POST['expense_id'] : null; $id = !empty($_POST['expense_id']) ? (int)$_POST['expense_id'] : null;
@@ -92,26 +103,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$amount = floatval($_POST['amount'] ?? 0); $amount = floatval($_POST['amount'] ?? 0);
$date = $_POST['date'] ?? date('Y-m-d'); $date = $_POST['date'] ?? date('Y-m-d');
// 🛡️ SÉCURITÉ : Format attendu pour gestion_month (qui vient de input type="month") : YYYY-MM. // Format attendu : YYYY-MM
$gestionMonthRaw = $_POST['gestion_month'] ?? ''; $gestionMonthRaw = $_POST['gestion_month'] ?? '';
// Si la valeur est vide ou '0000-00-00' // Si la valeur est vide ou invalide
if (empty($gestionMonthRaw) || strpos($gestionMonthRaw, '0000') !== false) { if (empty($gestionMonthRaw) || strpos($gestionMonthRaw, '0000') !== false) {
// On force le mois de gestion au 1er jour de la date de la dépense
$gestionMonth = date('Y-m-01', strtotime($date)); $gestionMonth = date('Y-m-01', strtotime($date));
} else { } else {
// Si la valeur vient d'un input "month" (ex: "2026-04"), on rajoute "-01"
$gestionMonth = (strlen($gestionMonthRaw) === 7) ? $gestionMonthRaw . '-01' : $gestionMonthRaw; $gestionMonth = (strlen($gestionMonthRaw) === 7) ? $gestionMonthRaw . '-01' : $gestionMonthRaw;
} }
$label = trim($_POST['label'] ?? ''); $label = trim($_POST['label'] ?? '');
$rawItemId = $_POST['budget_item_id'] ?? '';
$budgetItemId = null; $budgetItemId = null;
$salaryId = null;
$holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; $holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
if ($cat === 'School' && !empty($_POST['label_select'])) { if ($cat === 'School' && !empty($_POST['label_select'])) {
$label = trim($_POST['label_select']); $label = trim($_POST['label_select']);
} elseif (($cat === 'Frais' || $cat === 'Income') && !empty($_POST['budget_item_id'])) { } elseif (!empty($rawItemId)) {
$budgetItemId = (int)$_POST['budget_item_id']; // 🔥 On intercepte si c'est un Salaire (SAL_x) ou une Charge Fixe classique (x)
if (strpos($rawItemId, 'SAL_') === 0) {
$salaryId = (int)str_replace('SAL_', '', $rawItemId);
} else {
$budgetItemId = (int)$rawItemId;
}
} }
// Vérification de sécurité // Vérification de sécurité
@@ -124,14 +140,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$finalAmount = $is_credit ? abs($amount) : -abs($amount); $finalAmount = $is_credit ? abs($amount) : -abs($amount);
if ($id) { if ($id) {
// UPDATE // UPDATE (Ajout de salary_id)
$pdo->prepare("UPDATE pf_expenses SET date_exp=?, gestion_month=?, category=?, label=?, amount=?, budget_item_id=?, holiday_id=? WHERE id=?") $pdo->prepare("UPDATE pf_expenses SET date_exp=?, gestion_month=?, category=?, label=?, amount=?, budget_item_id=?, holiday_id=?, salary_id=? WHERE id=?")
->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $budgetItemId, $holidayId, $id]); ->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $budgetItemId, $holidayId, $salaryId, $id]);
} else { } else {
// INSERT // INSERT (Ajout de salary_id)
$uniqueRef = "MANUAL_" . uniqid(); $uniqueRef = "MANUAL_" . uniqid();
$pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id, salary_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $uniqueRef, $budgetItemId, $holidayId]); ->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $uniqueRef, $budgetItemId, $holidayId, $salaryId]);
} }
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
@@ -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()]);
}
+171 -48
View File
@@ -5,9 +5,30 @@ require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php'; require __DIR__ . '/../../../../includes/db.php';
require_login(); require_login();
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
// =================================================================
// LECTURE D'UNE NOTE (AJAX)
// =================================================================
if ($action === 'get_note') {
$noteType = $_POST['note_type'] ?? '';
$refId = $_POST['reference_id'] ?? '';
try {
$stmt = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = ? AND reference_id = ?");
$stmt->execute([$noteType, $refId]);
// fetchColumn() renvoie false si aucune ligne n'est trouvée, on sécurise avec un coalesce (?? '')
$content = $stmt->fetchColumn();
$content = $content !== false ? $content : '';
echo json_encode(['success' => true, 'content' => $content]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
// ================================================================= // =================================================================
// 7. SAUVEGARDE D'UNE NOTE GÉNÉRIQUE (pf_notes) // 7. SAUVEGARDE D'UNE NOTE GÉNÉRIQUE (pf_notes)
// ================================================================= // =================================================================
@@ -42,7 +63,7 @@ if ($action === 'save_note') {
// 1. MISE A JOUR TABLEAU SALAIRES (AJAX) // 1. MISE A JOUR TABLEAU SALAIRES (AJAX)
if ($action === 'update_salary_config') { if ($action === 'update_salary_config') {
header('Content-Type: application/json'); // On précise JSON ici header('Content-Type: application/json');
$year = $_POST['year']; $year = $_POST['year'];
$person = $_POST['person']; $person = $_POST['person'];
$field = $_POST['field']; // salary, mensualite, etc. $field = $_POST['field']; // salary, mensualite, etc.
@@ -50,7 +71,10 @@ if ($action === 'update_salary_config') {
// Liste des champs autorisés pour éviter les injections // Liste des champs autorisés pour éviter les injections
$allowed = ['salary', 'mensualite', 'frais_func', 'eco_perso', 'eco_family']; $allowed = ['salary', 'mensualite', 'frais_func', 'eco_perso', 'eco_family'];
if (!in_array($field, $allowed)) { echo json_encode(['error'=>'Champ invalide']); exit; } if (!in_array($field, $allowed)) {
echo json_encode(['success' => false, 'error' => 'Champ invalide']);
exit;
}
$stmt = $pdo->prepare("INSERT INTO pf_salary_config (year, person, $field) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE $field = VALUES($field)"); $stmt = $pdo->prepare("INSERT INTO pf_salary_config (year, person, $field) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE $field = VALUES($field)");
$stmt->execute([$year, $person, $value]); $stmt->execute([$year, $person, $value]);
@@ -62,28 +86,37 @@ if ($action === 'update_salary_config') {
if ($action === 'update_allocation') { if ($action === 'update_allocation') {
header('Content-Type: application/json'); header('Content-Type: application/json');
$date = $_POST['month_date']; $date = $_POST['month_date'];
$catId = $_POST['cat_id']; $catId = (int)$_POST['cat_id'];
$person = $_POST['person']; // 'amount_alex' ou 'amount_laia' $personId = (int)$_POST['person_id'];
$value = floatval($_POST['value']); $value = floatval($_POST['value']);
$stmt = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, $person) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE $person = VALUES($person)"); if ($catId <= 0 || $personId <= 0) {
$stmt->execute([$date, $catId, $value]); echo json_encode(['success' => false, 'error' => 'Données invalides.']);
exit;
}
$stmt = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, person_id, amount)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE amount = VALUES(amount)");
$stmt->execute([$date, $catId, $personId, $value]);
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
exit; exit;
} }
// 3. GESTION DES CATEGORIES (Ajout) // 3. GESTION DES CATEGORIES (Ajout)
if ($action === 'add_category') { if ($action === 'add_category') {
try {
$name = trim($_POST['name']); $name = trim($_POST['name']);
$target = trim($_POST['target']); $transfer_dest = trim($_POST['transfer_dest'] ?? '');
$target = floatval($_POST['target'] ?? 0);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; $holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
if (!empty($name)) { if (!empty($name)) {
$stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, holiday_id) VALUES (?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, transfer_dest, holiday_id) VALUES (?, ?, ?, ?)");
$stmt->execute([$name, $target, $holiday_id]); $stmt->execute([$name, $target, $transfer_dest, $holiday_id]);
} }
// --- NOUVEAU : Réponse AJAX ---
if (isset($_POST['ajax'])) { if (isset($_POST['ajax'])) {
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
@@ -91,21 +124,30 @@ if ($action === 'add_category') {
} }
header("Location: " . $_SERVER['HTTP_REFERER']); header("Location: " . $_SERVER['HTTP_REFERER']);
exit; exit;
} catch (Exception $e) {
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
}
die($e->getMessage());
}
} }
// 4. MODIFICATION D'UNE CATEGORIE // 4. MODIFICATION D'UNE CATEGORIE
if ($action === 'update_category') { if ($action === 'update_category') {
try {
$id = (int)$_POST['cat_id']; $id = (int)$_POST['cat_id'];
$name = trim($_POST['name']); $name = trim($_POST['name']);
$target = trim($_POST['target']); $transfer_dest = trim($_POST['transfer_dest'] ?? '');
$target = floatval($_POST['target'] ?? 0);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; $holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
if ($id > 0 && !empty($name)) { if ($id > 0 && !empty($name)) {
$stmt = $pdo->prepare("UPDATE pf_alloc_categories SET name = ?, target = ?, holiday_id = ? WHERE id = ?"); $stmt = $pdo->prepare("UPDATE pf_alloc_categories SET name = ?, target = ?, transfer_dest = ?, holiday_id = ? WHERE id = ?");
$stmt->execute([$name, $target, $holiday_id, $id]); $stmt->execute([$name, $target, $transfer_dest, $holiday_id, $id]);
} }
// --- NOUVEAU : Réponse AJAX ---
if (isset($_POST['ajax'])) { if (isset($_POST['ajax'])) {
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
@@ -113,6 +155,14 @@ if ($action === 'update_category') {
} }
header("Location: " . $_SERVER['HTTP_REFERER']); header("Location: " . $_SERVER['HTTP_REFERER']);
exit; exit;
} catch (Exception $e) {
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
}
die($e->getMessage());
}
} }
// 5. SUPPRESSION CATEGORIE // 5. SUPPRESSION CATEGORIE
@@ -136,36 +186,41 @@ if ($action === 'delete_category') {
// 6. VALIDATION DES VIREMENTS (Complex Business Logic) // 6. VALIDATION DES VIREMENTS (Complex Business Logic)
if ($action === 'validate_transfers') { if ($action === 'validate_transfers') {
header('Content-Type: application/json'); header('Content-Type: application/json');
$person = $_POST['person']; $personId = (int)$_POST['person_id']; // Reçoit l'ID parent
$monthDate = $_POST['month_date']; $monthDate = $_POST['month_date'];
try { try {
$pdo->beginTransaction(); $pdo->beginTransaction();
$stmtP = $pdo->prepare("SELECT name FROM pf_people WHERE id = ?");
$stmtP->execute([$personId]);
$dbPersonName = $stmtP->fetchColumn();
if (!$dbPersonName) throw new Exception("Parent introuvable.");
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT v.*, c.name as cat_name, c.target, c.holiday_id SELECT v.amount, c.name as cat_name, c.transfer_dest, c.holiday_id
FROM pf_alloc_values v FROM pf_alloc_values v
JOIN pf_alloc_categories c ON v.cat_id = c.id JOIN pf_alloc_categories c ON v.cat_id = c.id
WHERE v.month_date = ? WHERE v.month_date = ? AND v.person_id = ?
"); ");
$stmt->execute([$monthDate]); $stmt->execute([$monthDate, $personId]);
$budgetLines = $stmt->fetchAll(PDO::FETCH_ASSOC); $budgetLines = $stmt->fetchAll(PDO::FETCH_ASSOC);
$transfersToDo = []; $transfersToDo = [];
foreach ($budgetLines as $line) { foreach ($budgetLines as $line) {
$amount = ($person === 'Alex') ? $line['amount_alex'] : $line['amount_laia']; $amount = (float)$line['amount'];
if ($amount <= 0) continue; if ($amount <= 0) continue;
$target = trim($line['target']); $dest = trim($line['transfer_dest']);
$catName = trim($line['cat_name']); $catName = trim($line['cat_name']);
$holidayId = $line['holiday_id']; $holidayId = $line['holiday_id'];
$targetOwner = null; $targetOwner = null;
if ($target === 'vers L.Perso') { $targetOwner = $person; } if ($dest === 'vers L.Perso') { $targetOwner = $dbPersonName; }
elseif ($target === 'vers L.Pol') { $targetOwner = 'Pol'; } elseif ($dest === 'vers L.Pol') { $targetOwner = 'Pol'; }
elseif ($target === 'vers L.Pep') { $targetOwner = 'Pep'; } elseif ($dest === 'vers L.Pep') { $targetOwner = 'Pep'; }
elseif ($target === 'vers commune') { continue; } elseif ($dest === 'vers commune') { continue; }
if ($targetOwner) { if ($targetOwner) {
if (!isset($transfersToDo[$targetOwner])) { if (!isset($transfersToDo[$targetOwner])) {
@@ -181,7 +236,6 @@ if ($action === 'validate_transfers') {
} }
foreach ($transfersToDo as $owner => $data) { foreach ($transfersToDo as $owner => $data) {
// A. VERIFIER EXISTENCE (Inchangé)
$stmtCheck = $pdo->prepare("SELECT COUNT(*) FROM pf_savings WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'"); $stmtCheck = $pdo->prepare("SELECT COUNT(*) FROM pf_savings WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'");
$stmtCheck->execute([$owner, $monthDate]); $stmtCheck->execute([$owner, $monthDate]);
$exists = $stmtCheck->fetchColumn() > 0; $exists = $stmtCheck->fetchColumn() > 0;
@@ -200,54 +254,38 @@ if ($action === 'validate_transfers') {
} }
} }
// B. UPDATE TOTAL (Inchangé)
$stmtUpdTotal = $pdo->prepare("UPDATE pf_savings SET amount = amount + ? WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'"); $stmtUpdTotal = $pdo->prepare("UPDATE pf_savings SET amount = amount + ? WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'");
$stmtUpdTotal->execute([$data['total_add'], $owner, $monthDate]); $stmtUpdTotal->execute([$data['total_add'], $owner, $monthDate]);
// C. UPDATE CATÉGORIES (Modifié pour gérer le holiday_id)
foreach ($data['cats'] as $catName => $catInfo) { foreach ($data['cats'] as $catName => $catInfo) {
$catAmount = $catInfo['amount']; $catAmount = $catInfo['amount'];
$catHolidayId = $catInfo['holiday_id']; // NOUVEAU $catHolidayId = $catInfo['holiday_id'];
if ($catName === 'Eco Alex' || $catName === 'Eco Laia') { continue; } if (strpos($catName, 'Eco P') === 0) { continue; }
$stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?"); $stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
$stmtCheckCat->execute([$owner, $monthDate, $catName]); $stmtCheckCat->execute([$owner, $monthDate, $catName]);
$catId = $stmtCheckCat->fetchColumn(); $catId = $stmtCheckCat->fetchColumn();
if ($catId) { if ($catId) {
// Update : On actualise aussi le holiday_id au cas où il aurait changé
$stmtUpdateCat = $pdo->prepare("UPDATE pf_savings SET amount = amount + ?, holiday_id = ? WHERE id = ?"); $stmtUpdateCat = $pdo->prepare("UPDATE pf_savings SET amount = amount + ?, holiday_id = ? WHERE id = ?");
$stmtUpdateCat->execute([$catAmount, $catHolidayId, $catId]); $stmtUpdateCat->execute([$catAmount, $catHolidayId, $catId]);
} else { } else {
// Insert
$stmtInsertCat = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)"); $stmtInsertCat = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)");
$stmtInsertCat->execute([$monthDate, $owner, $catName, $catAmount, $catHolidayId]); $stmtInsertCat->execute([$monthDate, $owner, $catName, $catAmount, $catHolidayId]);
} }
} }
} }
// 3. ENREGISTRER LA VALIDATION (Mise à jour table existante)
// a. Trouver l'ID de la catégorie système
$stmtSys = $pdo->prepare("SELECT id FROM pf_alloc_categories WHERE name = 'SYSTEM_VALIDATION' LIMIT 1"); $stmtSys = $pdo->prepare("SELECT id FROM pf_alloc_categories WHERE name = 'SYSTEM_VALIDATION' LIMIT 1");
$stmtSys->execute(); $stmtSys->execute();
$sysCatId = $stmtSys->fetchColumn(); $sysCatId = $stmtSys->fetchColumn();
if ($sysCatId) { if ($sysCatId) {
$stmtVal = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, person_id, amount)
if ($person === 'Alex') { VALUES (?, ?, ?, 1)
$sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia) ON DUPLICATE KEY UPDATE amount = 1");
VALUES (?, ?, 1, 0) $stmtVal->execute([$monthDate, $sysCatId, $personId]);
ON DUPLICATE KEY UPDATE amount_alex = 1";
} else {
$sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia)
VALUES (?, ?, 0, 1)
ON DUPLICATE KEY UPDATE amount_laia = 1";
}
$stmtVal = $pdo->prepare($sql);
$stmtVal->execute([$monthDate, $sysCatId]);
} }
$pdo->commit(); $pdo->commit();
@@ -258,6 +296,91 @@ if ($action === 'validate_transfers') {
echo json_encode(['success' => false, 'error' => $e->getMessage()]); echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} }
exit; exit;
}
// =================================================================
// 7. GESTION DES AVANCES / TRICOUNT (pf_advances)
// =================================================================
if ($action === 'save_advance') {
header('Content-Type: application/json');
try {
$payer = trim($_POST['payer'] ?? '');
$advance_date = $_POST['advance_date'] ?? date('Y-m-d');
$description = trim($_POST['description'] ?? '');
$amount = abs((float)($_POST['amount'] ?? 0));
$from_savings = isset($_POST['from_savings']) ? 1 : 0;
if (empty($payer) || empty($description) || $amount <= 0) {
throw new Exception("Champs obligatoires manquants ou invalides.");
}
$stmt = $pdo->prepare("INSERT INTO pf_advances (advance_date, payer, description, amount, from_savings) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$advance_date, $payer, $description, $amount, $from_savings]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($action === 'update_advance') {
header('Content-Type: application/json');
try {
$id = (int)($_POST['id'] ?? 0);
$payer = trim($_POST['payer'] ?? '');
$advance_date = $_POST['advance_date'] ?? date('Y-m-d');
$description = trim($_POST['description'] ?? '');
$amount = abs((float)($_POST['amount'] ?? 0));
$from_savings = isset($_POST['from_savings']) ? 1 : 0;
if ($id <= 0 || empty($payer) || empty($description) || $amount <= 0) {
throw new Exception("Paramètres invalides fournis.");
}
$stmt = $pdo->prepare("UPDATE pf_advances SET advance_date = ?, payer = ?, description = ?, amount = ?, from_savings = ? WHERE id = ?");
$stmt->execute([$advance_date, $payer, $description, $amount, $from_savings, $id]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($action === 'resolve_advance') {
header('Content-Type: application/json');
try {
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) throw new Exception("ID invalide fourni.");
$stmt = $pdo->prepare("UPDATE pf_advances SET is_resolved = 1 WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($action === 'delete_advance') {
header('Content-Type: application/json');
try {
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) throw new Exception("ID invalide.");
$stmt = $pdo->prepare("DELETE FROM pf_advances WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
} }
+51 -27
View File
@@ -3,15 +3,23 @@ require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php'; require __DIR__ . '/../../../../includes/db.php';
require_login(); require_login();
// 1. Déclaration propre des variables d'entrée
$action = $_POST['action'] ?? '';
$isAjax = !empty($_POST['ajax']);
// Si c'est de l'AJAX, on force les en-têtes HTTP pour garantir du JSON
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
}
// ================================================================= // =================================================================
// MISE À JOUR D'UNE CELLULE EN DIRECT (AJAX) // MISE À JOUR D'UNE CELLULE EN DIRECT (AJAX)
// ================================================================= // =================================================================
if ($action === 'update_single_entry') { if ($action === 'update_single_entry') {
header('Content-Type: application/json'); $month = $_POST['month_date'] ?? '';
$month = $_POST['month_date']; $cat = $_POST['category'] ?? '';
$cat = $_POST['category']; $owner = $_POST['owner'] ?? '';
$owner = $_POST['owner']; $amount = (float)($_POST['amount'] ?? 0);
$amount = (float)$_POST['amount'];
try { try {
if ($amount == 0 && $cat !== 'TOTAL_BANQUE') { if ($amount == 0 && $cat !== 'TOTAL_BANQUE') {
@@ -31,10 +39,10 @@ if ($action === 'update_single_entry') {
} }
// --- ACTION : SUPPRESSION D'UNE ENTRÉE UNIQUE --- // --- ACTION : SUPPRESSION D'UNE ENTRÉE UNIQUE ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_entry') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'delete_entry') {
$owner = $_POST['owner']; $owner = $_POST['owner'] ?? '';
$date = $_POST['month_date']; $date = $_POST['month_date'] ?? '';
$cat = $_POST['category']; $cat = $_POST['category'] ?? '';
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?"); $stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
$stmt->execute([$owner, $date, $cat]); $stmt->execute([$owner, $date, $cat]);
@@ -44,11 +52,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
} }
// --- ACTION : DUPLICATION DE MOIS --- // --- ACTION : DUPLICATION DE MOIS ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'duplicate_month') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'duplicate_month') {
$owner = $_POST['owner']; $owner = $_POST['owner'] ?? '';
$sourceDate = $_POST['source_date']; $sourceDate = $_POST['source_date'] ?? '';
$targetDate = $_POST['target_date']; $targetDate = $_POST['target_date'] ?? '';
$newTotal = floatval($_POST['new_total']); $newTotal = floatval($_POST['new_total'] ?? 0);
try { try {
$pdo->beginTransaction(); $pdo->beginTransaction();
@@ -78,16 +86,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'dupli
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
} catch (Exception $e) { } catch (Exception $e) {
$pdo->rollBack(); if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]); echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} }
exit; exit;
} }
// --- ACTION : SUPPRESSION GLOBALE D'UN MOIS --- // --- ACTION : SUPPRESSION GLOBALE D'UN MOIS ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_month_global') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'delete_month_global') {
$owner = $_POST['owner']; $owner = $_POST['owner'] ?? '';
$date = $_POST['month_date']; $date = $_POST['month_date'] ?? '';
try { try {
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ?"); $stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ?");
@@ -101,18 +109,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
} }
// --- ACTION : SAUVEGARDE CLASSIQUE (MODALE) --- // --- ACTION : SAUVEGARDE CLASSIQUE (MODALE) ---
if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Cette action s'exécute si c'est un POST général sans action précise (le form HTML) ou l'action explicitement nommée.
$owner = $_POST['owner']; if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($action, ['save_modal', ''])) {
$owner = $_POST['owner'] ?? '';
// CORRECTION ICI : On récupère l'onglet de redirection (Nens, Alex ou Laia)
$redirectTab = $_POST['redirect_tab'] ?? $owner; $redirectTab = $_POST['redirect_tab'] ?? $owner;
$dateInput = $_POST['month_date']; $dateInput = $_POST['month_date'] ?? '';
$dateObj = new DateTime($dateInput);
$monthDate = $dateObj->format('Y-m-01');
$values = $_POST['values'] ?? []; // Tableau généré par nos champs JS: values[Catégorie] $values = $_POST['values'] ?? []; // Tableau généré par nos champs JS: values[Catégorie]
try { try {
$dateObj = new DateTime($dateInput);
$monthDate = $dateObj->format('Y-m-01');
$pdo->beginTransaction(); $pdo->beginTransaction();
// On supprime d'abord les anciennes données du mois pour cet utilisateur // On supprime d'abord les anciennes données du mois pour cet utilisateur
@@ -130,11 +138,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
$pdo->commit(); $pdo->commit();
// Redirection vers le bon onglet // On répond proprement selon le contexte (AJAX ou Fallback classique)
if ($isAjax) {
echo json_encode(['success' => true]);
} else {
header("Location: /budget.php?tab=epargne&owner=" . urlencode($redirectTab)); header("Location: /budget.php?tab=epargne&owner=" . urlencode($redirectTab));
}
exit; exit;
} catch (Exception $e) { } catch (Exception $e) {
$pdo->rollBack(); if ($pdo->inTransaction()) $pdo->rollBack();
if ($isAjax) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} else {
die("Erreur : " . $e->getMessage()); die("Erreur : " . $e->getMessage());
} }
exit;
}
}
// Sécurité finale : Si l'action n'est pas reconnue
if ($isAjax) {
echo json_encode(['success' => false, 'error' => 'Action inconnue']);
exit;
} }
+190
View File
@@ -0,0 +1,190 @@
<?php
require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
require_login();
header('Content-Type: application/json');
$action = $_GET['action'] ?? $_POST['action'] ?? '';
try {
if ($action === 'get_all') {
// 1. Comptes bancaires avec le nom du propriétaire lié (pf_people)
$accounts = $pdo->query("
SELECT a.*, p.name as owner_name
FROM pf_bank_accounts a
LEFT JOIN pf_people p ON a.owner_person_id = p.id
ORDER BY a.is_default DESC, a.name ASC
")->fetchAll(PDO::FETCH_ASSOC);
// 2. Catégories
$categories = $pdo->query("SELECT * FROM pf_budget_categories ORDER BY type ASC, label ASC")->fetchAll(PDO::FETCH_ASSOC);
// 3. Règles d'import
$rules = $pdo->query("
SELECT r.*, c.label as cat_label
FROM pf_import_rules r
LEFT JOIN pf_budget_categories c ON r.category COLLATE utf8mb4_unicode_ci = c.code COLLATE utf8mb4_unicode_ci
ORDER BY r.keyword ASC
")->fetchAll(PDO::FETCH_ASSOC);
// 4. Salaires (Année en cours)
$currentYear = (int)date('Y');
$salaries = $pdo->query("
SELECT p.name as person,
COALESCE(s.salary, 0) as salary,
COALESCE(s.mensualite, 0) as mensualite,
s.id as id
FROM pf_people p
LEFT JOIN pf_salary_config s ON p.name = s.person AND s.year = $currentYear
WHERE p.role NOT IN ('enfant', 'nounou') AND p.is_active = 1
ORDER BY p.name ASC
")->fetchAll(PDO::FETCH_ASSOC);
// 5. Paramètres Foyer (Devise + Mapping CSV)
$foyerData = $pdo->query("SELECT currency, csv_mapping FROM pf_foyer_settings LIMIT 1")->fetch(PDO::FETCH_ASSOC);
$currencySetting = $foyerData['currency'] ?? '€';
$csvMapping = !empty($foyerData['csv_mapping']) ? json_decode($foyerData['csv_mapping'], true) : null;
echo json_encode([
'success' => true,
'data' => [
'accounts' => $accounts,
'categories' => $categories,
'rules' => $rules,
'salaries' => $salaries,
'year' => $currentYear,
'currency' => $currencySetting,
'csv_mapping' => $csvMapping
]
]);
exit;
}
// --- GESTION DES COMPTES BANCAIRES ---
if ($action === 'add_account') {
$name = trim($_POST['name'] ?? '');
$type = $_POST['type'] ?? 'checking';
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]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_account') {
$id = (int)($_POST['id'] ?? 0);
$count = $pdo->query("SELECT COUNT(*) FROM pf_bank_accounts")->fetchColumn();
if ($count <= 1) throw new Exception("Impossible de supprimer le dernier compte.");
$stmt = $pdo->prepare("DELETE FROM pf_bank_accounts WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
// --- GESTION DES CATÉGORIES ---
if ($action === 'add_category') {
$code = strtoupper(trim($_POST['code'] ?? ''));
$label = trim($_POST['label'] ?? '');
$type = $_POST['type'] ?? 'Expense';
$color = $_POST['color'] ?? '#cccccc';
$icon = trim($_POST['icon'] ?? '📌');
if (empty($code) || empty($label)) throw new Exception("Le code et le libellé sont obligatoires.");
$check = $pdo->prepare("SELECT COUNT(*) FROM pf_budget_categories WHERE code = ?");
$check->execute([$code]);
if ($check->fetchColumn() > 0) throw new Exception("Ce code de catégorie existe déjà.");
$stmt = $pdo->prepare("INSERT INTO pf_budget_categories (code, label, type, color, icon) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$code, $label, $type, $color, $icon]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_category') {
$id = (int)($_POST['id'] ?? 0);
$stmt = $pdo->prepare("DELETE FROM pf_budget_categories WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
// --- GESTION DES RÈGLES D'IMPORT ---
if ($action === 'add_rule') {
$keyword = strtoupper(trim($_POST['keyword'] ?? ''));
$category = trim($_POST['category'] ?? '');
if (empty($keyword) || empty($category)) throw new Exception("Le mot-clé et la catégorie sont obligatoires.");
$check = $pdo->prepare("SELECT COUNT(*) FROM pf_import_rules WHERE keyword = ?");
$check->execute([$keyword]);
if ($check->fetchColumn() > 0) throw new Exception("Une règle pour ce mot-clé existe déjà.");
$stmt = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category) VALUES (?, ?)");
$stmt->execute([$keyword, $category]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_rule') {
$id = (int)($_POST['id'] ?? 0);
$stmt = $pdo->prepare("DELETE FROM pf_import_rules WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
// --- GESTION DES SALAIRES ---
if ($action === 'save_salary') {
$id = (int)($_POST['id'] ?? 0);
$salary = (float)($_POST['salary'] ?? 0);
$mensualite = (float)($_POST['mensualite'] ?? 0);
if ($id <= 0) throw new Exception("ID de configuration de salaire invalide.");
$stmt = $pdo->prepare("UPDATE pf_salary_config SET salary = ?, mensualite = ? WHERE id = ?");
$stmt->execute([$salary, $mensualite, $id]);
echo json_encode(['success' => true]);
exit;
}
// --- GESTION DE LA DEVISE GLOBALE ---
if ($action === 'save_currency') {
$currency = trim($_POST['currency'] ?? '€');
$stmt = $pdo->prepare("UPDATE pf_foyer_settings SET currency = ?");
$stmt->execute([$currency]);
echo json_encode(['success' => true]);
exit;
}
// --- GESTION DU FORMAT CSV ---
if ($action === 'save_csv_mapping') {
$mapping = [
'delimiter' => $_POST['csv_delimiter'] ?? ';',
'date_format' => $_POST['csv_date_format'] ?? 'd/m/Y',
'col_date' => (int)($_POST['csv_col_date'] ?? 0),
'col_label' => (int)($_POST['csv_col_label'] ?? 1),
'amount_type' => $_POST['csv_amount_type'] ?? 'single',
'col_debit' => (int)($_POST['csv_col_debit'] ?? 8),
'col_credit' => (int)($_POST['csv_col_credit'] ?? 9),
'col_ref' => (int)($_POST['csv_col_ref'] ?? 3)
];
$jsonContent = json_encode($mapping);
$stmt = $pdo->prepare("UPDATE pf_foyer_settings SET csv_mapping = ?");
$stmt->execute([$jsonContent]);
echo json_encode(['success' => true]);
exit;
}
// SAUVEGARDE DU NOUVEL ORDRE DES CATÉGORIES
if (isset($_POST['action']) && $_POST['action'] === 'save_categories_order') {
$ids = json_decode($_POST['order'], true);
if (is_array($ids)) {
$stmt = $pdo->prepare("UPDATE pf_budget_categories SET sort_order = ? WHERE id = ?");
foreach ($ids as $index => $id) {
$stmt->execute([$index, (int)$id]);
}
}
echo json_encode(['success' => true]);
exit;
}
throw new Exception("Action API inconnue.");
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
?>
File diff suppressed because it is too large Load Diff
+118 -63
View File
@@ -1,9 +1,31 @@
<?php <?php
// modules/budget/views/epargne.php // modules/budget/views/epargne.php
$requestedOwner = $_GET['owner'] ?? 'Nens'; // --- 🧠 CONFIGURATION AGNOSTIQUE DES MEMBRES (MULTI-TENANT) ---
$ownersToDisplay = ($requestedOwner === 'Nens') ? ['Pol', 'Pep'] : [$requestedOwner]; $stmtPeople = $pdo->query("SELECT name, role FROM pf_people ORDER BY id ASC");
$familyParents = [];
$familyKids = [];
while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) {
$role = strtolower(trim($row['role'] ?? ''));
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'));
$ownersToDisplay = ($requestedOwner === 'KIDS') ? $familyKids : [$requestedOwner];
// --- RÉCUPÉRATION CONFIGURATION DES MOIS ---
$cycleConfigs = []; $cycleConfigs = [];
$stmtNotes = $pdo->query("SELECT reference_id, content FROM pf_notes WHERE note_type = 'month_config'"); $stmtNotes = $pdo->query("SELECT reference_id, content FROM pf_notes WHERE note_type = 'month_config'");
while ($row = $stmtNotes->fetch(PDO::FETCH_ASSOC)) { while ($row = $stmtNotes->fetch(PDO::FETCH_ASSOC)) {
@@ -14,7 +36,6 @@ while ($row = $stmtNotes->fetch(PDO::FETCH_ASSOC)) {
} }
} }
// Récupération sécurisée du nom des mois (utilise les clés globales existantes)
function getMonthName($dateString) { function getMonthName($dateString) {
$m = date('m', strtotime($dateString)); $m = date('m', strtotime($dateString));
$y = date('Y', strtotime($dateString)); $y = date('Y', strtotime($dateString));
@@ -25,13 +46,22 @@ function getMonthName($dateString) {
<div class="budget-view"> <div class="budget-view">
<div class="view-header"> <div class="view-header">
<div class="owner-tabs"> <div class="owner-tabs">
<a href="?tab=epargne&owner=Alex" class="owner-tab <?= $requestedOwner === 'Alex' ? 'active' : '' ?>">Alex</a>
<a href="?tab=epargne&owner=Laia" class="owner-tab <?= $requestedOwner === 'Laia' ? 'active' : '' ?>">Laia</a> <?php if (!empty($familyKids)): ?>
<a href="?tab=epargne&owner=Nens" class="owner-tab <?= $requestedOwner === 'Nens' ? 'active' : '' ?>">Nens 👶</a> <a href="?tab=epargne&owner=KIDS" class="owner-tab <?= $requestedOwner === 'KIDS' ? 'active' : '' ?>">
<?= tr('budget_tab_kids') ?? 'Enfants 👶' ?>
</a>
<?php endif; ?>
<?php foreach ($familyParents as $p): ?>
<a href="?tab=epargne&owner=<?= urlencode($p) ?>" class="owner-tab <?= $requestedOwner === $p ? 'active' : '' ?>">
<?= htmlspecialchars($p) ?>
</a>
<?php endforeach; ?>
</div> </div>
</div> </div>
<?php foreach ($ownersToDisplay as $currentOwner): <?php foreach ($ownersToDisplay as $index => $currentOwner):
$stmt = $pdo->prepare("SELECT month_date, category, amount FROM pf_savings WHERE owner = ? ORDER BY month_date DESC"); $stmt = $pdo->prepare("SELECT month_date, category, amount FROM pf_savings WHERE owner = ? ORDER BY month_date DESC");
$stmt->execute([$currentOwner]); $stmt->execute([$currentOwner]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
@@ -51,31 +81,29 @@ function getMonthName($dateString) {
$months = array_slice($months, 0, 7); $months = array_slice($months, 0, 7);
sort($allCategories); sort($allCategories);
// Définition de la classe couleur selon le propriétaire $ownerTextClass = 'txt-global';
$ownerTextClass = ''; // 🔄 CORRECTION ICI : Création d'un nom "safe" sans espace pour les classes CSS
if ($currentOwner === 'Alex') $ownerTextClass = 'txt-alex'; $safeOwnerCls = htmlspecialchars(str_replace(' ', '_', $currentOwner), ENT_QUOTES);
elseif ($currentOwner === 'Laia') $ownerTextClass = 'txt-laia';
else $ownerTextClass = 'txt-global'; // Pour Pol et Pep
?> ?>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; margin-top: <?= ($requestedOwner === 'Nens' && $currentOwner !== 'Pol') ? '40px' : '0' ?>;"> <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;"> <div style="flex-grow: 1;">
<?php if ($requestedOwner === 'Nens'): <?php if ($requestedOwner === 'KIDS'):
$themeClass = 'theme-' . strtolower($currentOwner); $themeClass = 'theme-' . strtolower($safeOwnerCls);
?> ?>
<h3 class="nens-title <?= $themeClass ?>" style="margin:0; font-size:1.2rem;"> <h3 class="nens-title <?= $themeClass ?>" style="margin:0; font-size:1.2rem;">
<?= $currentOwner ?> <?= htmlspecialchars($currentOwner) ?>
</h3> </h3>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div style="display: flex; gap: 10px; align-items: center;"> <div style="display: flex; gap: 10px; align-items: center;">
<?php if (!empty($months)): ?> <?php if (!empty($months)): ?>
<button onclick="duplicateLastMonth('<?= $months[0] ?>', '<?= $currentOwner ?>')" class="pf-btn btn-secondary"> <button onclick="duplicateLastMonth('<?= $months[0] ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn btn-secondary">
🔁 <?= tr('bud_sav_add_one_month') ?> 🔁 <?= tr('bud_sav_add_one_month') ?>
</button> </button>
<?php endif; ?> <?php endif; ?>
<button onclick="openCustomSavingsModal('<?= $currentOwner ?>')" class="pf-btn"> <button onclick="openCustomSavingsModal('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn">
<?= tr('bud_sav_add_month') ?> <?= tr('bud_sav_add_month') ?>
</button> </button>
</div> </div>
@@ -87,7 +115,7 @@ function getMonthName($dateString) {
<p><?= sprintf(tr('bud_sav_no_data'), htmlspecialchars($currentOwner)) ?></p> <p><?= sprintf(tr('bud_sav_no_data'), htmlspecialchars($currentOwner)) ?></p>
</div> </div>
<?php else: ?> <?php else: ?>
<table class="pf-table savings-table nens-table theme-<?= strtolower($currentOwner) ?>" style="margin-top:0; box-shadow:none; border-radius:16px;"> <table class="pf-table savings-table nens-table theme-<?= strtolower($safeOwnerCls) ?>" style="margin-top:0; box-shadow:none; border-radius:16px;">
<thead> <thead>
<tr> <tr>
<th class="sticky-col" style="background:#f8fafc;"><?= tr('bud_sav_post_month') ?></th> <th class="sticky-col" style="background:#f8fafc;"><?= tr('bud_sav_post_month') ?></th>
@@ -106,11 +134,11 @@ function getMonthName($dateString) {
<div class="month-actions" style="justify-content: center; width: 100%;"> <div class="month-actions" style="justify-content: center; width: 100%;">
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_edit_modal') ?>" <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') ?>" data-json="<?= htmlspecialchars(json_encode($data[$month] ?? []), ENT_QUOTES, 'UTF-8') ?>"
onclick='editCustomSavingsMonth("<?= $month ?>", "<?= $currentOwner ?>", JSON.parse(this.getAttribute("data-json")))'> onclick='editCustomSavingsMonth("<?= $month ?>", "<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>", JSON.parse(this.getAttribute("data-json")))'>
✏️ ✏️
</button> </button>
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_delete_month') ?>" <button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_delete_month') ?>"
onclick="deleteEntireMonth('<?= $month ?>', '<?= $currentOwner ?>')" onclick="deleteEntireMonth('<?= $month ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')"
style="color: #ef4444; border-color: #fca5a5; background: #fef2f2;"> style="color: #ef4444; border-color: #fca5a5; background: #fef2f2;">
🗑️ 🗑️
</button> </button>
@@ -129,11 +157,11 @@ function getMonthName($dateString) {
<td class="text-center" style="padding:4px;"> <td class="text-center" style="padding:4px;">
<div style="display:flex; align-items:center; justify-content:center; gap:2px;"> <div style="display:flex; align-items:center; justify-content:center; gap:2px;">
<input type="number" step="0.01" <input type="number" step="0.01"
class="prev-input total-input-<?= $currentOwner ?>-<?= $month ?>" class="prev-input total-input-<?= $safeOwnerCls ?>-<?= $month ?>"
style="width: 70px; font-weight:bold; color:#2563eb;" style="width: 70px; font-weight:bold; color:#2563eb;"
value="<?= $val != 0 ? round($val) : '' ?>" value="<?= $val != 0 ? round($val) : '' ?>"
placeholder="0" placeholder="0"
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= $currentOwner ?>', this)"> onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
<span style="color:#2563eb; font-weight:bold; font-size:0.9rem;">€</span> <span style="color:#2563eb; font-weight:bold; font-size:0.9rem;">€</span>
</div> </div>
</td> </td>
@@ -149,11 +177,11 @@ function getMonthName($dateString) {
<td class="text-center" style="padding:4px;"> <td class="text-center" style="padding:4px;">
<div style="display:flex; align-items:center; justify-content:center; gap:2px;"> <div style="display:flex; align-items:center; justify-content:center; gap:2px;">
<input type="number" step="0.01" <input type="number" step="0.01"
class="prev-input <?= $ownerTextClass ?> cat-input-<?= $currentOwner ?>-<?= $month ?>" class="prev-input <?= $ownerTextClass ?> cat-input-<?= $safeOwnerCls ?>-<?= $month ?>"
style="width: 70px;" style="width: 70px;"
value="<?= $amount != 0 ? round($amount) : '' ?>" value="<?= $amount != 0 ? round($amount) : '' ?>"
placeholder="-" placeholder="-"
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= $currentOwner ?>', this)"> onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
</div> </div>
</td> </td>
<?php endforeach; ?> <?php endforeach; ?>
@@ -168,7 +196,7 @@ function getMonthName($dateString) {
foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0); foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0);
$extra = $total - $sum; $extra = $total - $sum;
?> ?>
<td class="text-center font-bold sum-target" id="extra_<?= $currentOwner ?>_<?= $month ?>" style="color: <?= $extra >= 0 ? '#10b981' : '#ef4444' ?>; padding:12px;"> <td class="text-center font-bold sum-target" id="extra_<?= $safeOwnerCls ?>_<?= $month ?>" style="color: <?= $extra >= 0 ? '#10b981' : '#ef4444' ?>; padding:12px;">
<?= number_format($extra, 0, ',', ' ') ?> € <?= number_format($extra, 0, ',', ' ') ?> €
</td> </td>
<?php endforeach; ?> <?php endforeach; ?>
@@ -260,10 +288,13 @@ window.I18N = {
'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?> 'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?>
}; };
// --- 2. GESTION DE L'ÉDITION INVISIBLE EN DIRECT --- // 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 ?? []) ?>; const cycleConfigs = <?= json_encode($cycleConfigs ?? []) ?>;
function updateEpargneCell(month, category, owner, inputEl) { async function updateEpargneCell(month, category, owner, inputEl) {
const val = parseFloat(inputEl.value) || 0; const val = parseFloat(inputEl.value) || 0;
const formData = new FormData(); const formData = new FormData();
formData.append('action', 'update_single_entry'); formData.append('action', 'update_single_entry');
@@ -271,22 +302,38 @@ function updateEpargneCell(month, category, owner, inputEl) {
formData.append('category', category); formData.append('category', category);
formData.append('owner', owner); formData.append('owner', owner);
formData.append('amount', val); formData.append('amount', val);
formData.append('ajax', '1'); // 🚀 CRITIQUE : Informe l'API
fetch('modules/budget/includes/api/save-savings.php', { try {
// 🚀 CRITIQUE : pachaFetch + Chemin absolu
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', {
method: 'POST', method: 'POST',
body: formData body: formData
}).catch(err => alert(window.I18N['bud_err_tech'] || 'Erreur technique')); });
const totalInput = document.querySelector(`.total-input-${owner}-${month}`); 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é
}
} catch (err) {
console.error("Erreur réseau :", err);
alert(window.I18N['bud_err_tech'] || 'Erreur technique');
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; const totalVal = parseFloat(totalInput ? totalInput.value : 0) || 0;
let sumCats = 0; let sumCats = 0;
document.querySelectorAll(`.cat-input-${owner}-${month}`).forEach(inp => { document.querySelectorAll(`.cat-input-${CSS.escape(safeOwnerClass)}-${month}`).forEach(inp => {
sumCats += parseFloat(inp.value) || 0; sumCats += parseFloat(inp.value) || 0;
}); });
const extra = totalVal - sumCats; const extra = totalVal - sumCats;
const extraCell = document.getElementById(`extra_${owner}_${month}`); const extraCell = document.getElementById(`extra_${safeOwnerClass}_${month}`);
if (extraCell) { if (extraCell) {
extraCell.innerText = Math.round(extra).toLocaleString(window.appLang) + ' €'; extraCell.innerText = Math.round(extra).toLocaleString(window.appLang) + ' €';
@@ -390,7 +437,7 @@ window.onclick = function(event) {
const savingsForm = document.getElementById('savingsForm'); const savingsForm = document.getElementById('savingsForm');
if (savingsForm) { if (savingsForm) {
savingsForm.addEventListener('submit', function(e) { savingsForm.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
const ym = document.getElementById('sav_month').value; const ym = document.getElementById('sav_month').value;
@@ -404,23 +451,32 @@ if (savingsForm) {
submitBtn.disabled = true; submitBtn.disabled = true;
const formData = new FormData(this); const formData = new FormData(this);
formData.append('ajax', '1'); // 🚀 CRITIQUE
const actionUrl = this.getAttribute('action'); try {
const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl; // 🚀 CRITIQUE : pachaFetch + Chemin absolu forcé
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', {
method: 'POST',
body: formData
});
fetch(finalUrl, { method: 'POST', body: formData }) if (result.success) {
.then(response => response.text()) window.location.reload(); // Ne recharge QUE si le serveur a dit OK
.then(text => { window.location.reload(); }) } else {
.catch(error => { alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (result.error || "Inconnue"));
console.error("Erreur:", error); submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
} catch (error) {
console.error("Erreur catch:", error);
alert(window.I18N['bud_err_tech'] || 'Erreur Technique'); alert(window.I18N['bud_err_tech'] || 'Erreur Technique');
submitBtn.innerText = originalText; submitBtn.innerText = originalText;
submitBtn.disabled = false; submitBtn.disabled = false;
}); }
}); });
} }
function deleteEntireMonth(monthDate, owner) { 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'] || "Supprimer %m pour %o ?";
const msg = rawMsg.replace('%m', monthDate).replace('%o', owner); const msg = rawMsg.replace('%m', monthDate).replace('%o', owner);
if (!confirm(msg)) return; if (!confirm(msg)) return;
@@ -429,13 +485,18 @@ function deleteEntireMonth(monthDate, owner) {
formData.append("action", "delete_month_global"); formData.append("action", "delete_month_global");
formData.append("month_date", monthDate); formData.append("month_date", monthDate);
formData.append("owner", owner); formData.append("owner", owner);
formData.append("ajax", "1"); // 🚀 CRITIQUE
fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData }) try {
.then(() => window.location.reload()) const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: "POST", body: formData });
.catch(err => alert(window.I18N['bud_err_delete'] || 'Erreur lors de la suppression')); 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');
}
} }
function duplicateLastMonth(lastMonthDate, owner) { async function duplicateLastMonth(lastMonthDate, owner) {
let dateObj = new Date(lastMonthDate); let dateObj = new Date(lastMonthDate);
dateObj.setMonth(dateObj.getMonth() + 1); dateObj.setMonth(dateObj.getMonth() + 1);
let year = dateObj.getFullYear(); let year = dateObj.getFullYear();
@@ -447,16 +508,13 @@ function duplicateLastMonth(lastMonthDate, owner) {
return str.charAt(0).toUpperCase() + str.slice(1); return str.charAt(0).toUpperCase() + str.slice(1);
}; };
const sourceName = formatMonth(lastMonthDate);
const targetName = formatMonth(nextMonthStr);
let defaultTotal = ""; let defaultTotal = "";
if (cycleConfigs[nextMonthStr] && cycleConfigs[nextMonthStr].start_balance !== undefined) { if (cycleConfigs[nextMonthStr] && cycleConfigs[nextMonthStr].start_balance !== undefined) {
defaultTotal = cycleConfigs[nextMonthStr].start_balance; defaultTotal = cycleConfigs[nextMonthStr].start_balance;
} }
const rawMsg = window.I18N['bud_sav_prompt_duplicate'] || "Dupliquer %s vers %t1 ?"; const rawMsg = window.I18N['bud_sav_prompt_duplicate'] || "Dupliquer vers le mois suivant ?";
const message = rawMsg.replace('%s', sourceName).replace('%t1', targetName).replace('%t2', targetName); const message = rawMsg.replace('%s', formatMonth(lastMonthDate)).replace(/%t[12]/g, formatMonth(nextMonthStr));
let newTotal = prompt(message, defaultTotal); let newTotal = prompt(message, defaultTotal);
@@ -467,22 +525,19 @@ function duplicateLastMonth(lastMonthDate, owner) {
formData.append("target_date", nextMonthStr); formData.append("target_date", nextMonthStr);
formData.append("new_total", newTotal); formData.append("new_total", newTotal);
formData.append("owner", owner); formData.append("owner", owner);
formData.append("ajax", "1"); // 🚀 CRITIQUE
fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
.then(async r => {
const text = await r.text();
try { try {
const d = JSON.parse(text); const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: "POST", body: formData });
if (d.success) window.location.reload(); if (result.success) {
else alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (d.error || "Inconnue"));
} catch(e) {
window.location.reload(); window.location.reload();
} else {
alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (result.error || "Inconnue"));
} }
}) } catch(err) {
.catch(err => {
console.error(err); console.error(err);
alert(window.I18N['bud_err_network_dup'] || 'Erreur réseau.'); alert(window.I18N['bud_err_network_dup'] || 'Erreur réseau.');
}); }
} }
} }
@@ -528,7 +583,7 @@ function updateSumResult() {
total += val; total += val;
}); });
document.getElementById('sumResultValue').innerText = Math.round(total).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY; document.getElementById('sumResultValue').innerText = Math.round(total).toLocaleString(window.appLang) + ' ' + systemCurrency;
} }
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
+265
View File
@@ -0,0 +1,265 @@
<?php
// modules/budget/views/provisions.php
// --- RÉCUPÉRATION DES DÉPENSES PRÉVUES (MULTI-TENANT PDO) ---
$stmt = $pdo->query("SELECT id, title, amount, expected_date FROM pf_expected_expenses WHERE is_paid = 0 ORDER BY expected_date ASC");
$provisions = $stmt->fetchAll(PDO::FETCH_ASSOC);
// --- RÉCUPÉRATION DES APPORTS ET DETTES (CLEARING) ---
$currentYear = date('Y');
// 1. Apports de base (Eco Family) des parents configurés pour l'année
$stmtConfig = $pdo->prepare("SELECT person, eco_family FROM pf_salary_config WHERE year = ?");
$stmtConfig->execute([$currentYear]);
$configs = $stmtConfig->fetchAll(PDO::FETCH_KEY_PAIR);
// 2. Dettes non résolues par personne (issues des Avances & Tricount)
$stmtDebts = $pdo->query("SELECT payer, SUM(amount) as total_debt FROM pf_advances WHERE is_resolved = 0 GROUP BY payer");
$debts = $stmtDebts->fetchAll(PDO::FETCH_KEY_PAIR);
function formatDate($dateString) {
if (!$dateString) return '';
return date('d/m/Y', strtotime($dateString));
}
?>
<div class="budget-view">
<div class="view-header">
<h2><?= tr('budget_provisions_title') ?></h2>
<button class="pf-btn pf-btn-primary" onclick="openOptimizeModal()">
✨ <?= tr('btn_optimize_cashflow') ?>
</button>
</div>
<div class="pf-card">
<h3 class="pf-card-h2"><?= tr('add_new_provision') ?></h3>
<form id="form-add-provision" data-action="/modules/budget/includes/api/add-provision.php">
<div class="provisions-form-grid">
<div class="pf-form-group">
<label class="pf-label" for="prov-title"><?= tr('provision_label') ?></label>
<input type="text" id="prov-title" name="title" class="pf-input" placeholder="<?= tr('provision_placeholder_wood') ?>" required>
</div>
<div class="pf-form-group">
<label class="pf-label" for="prov-amount"><?= tr('amount') ?> (€)</label>
<input type="number" id="prov-amount" name="amount" class="pf-input no-spinners input-amount-highlight" step="0.01" min="0.01" required>
</div>
<div class="pf-form-group">
<label class="pf-label" for="prov-date"><?= tr('expected_date') ?></label>
<input type="date" id="prov-date" name="expected_date" class="pf-input" required>
</div>
<button type="submit" class="pf-btn pf-btn-secondary">
<?= tr('btn_add') ?>
</button>
</div>
</form>
</div>
<div class="budget-table-card table-responsive">
<?php if (empty($provisions)): ?>
<div class="optimization-hint" style="padding: 30px;">
<p><?= tr('no_provisions') ?></p>
</div>
<?php else: ?>
<table class="pf-table savings-table provisions-table">
<thead>
<tr>
<th><?= tr('provision_label') ?></th>
<th style="width: 150px;"><?= tr('amount') ?></th>
<th style="width: 150px;"><?= tr('expected_date') ?></th>
<th style="width: 100px;"><?= tr('actions') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($provisions as $p): ?>
<tr>
<td class="prov-title-cell">
<?= htmlspecialchars($p['title']) ?>
</td>
<td class="prov-amount-cell">
<?= number_format($p['amount'], 2, ',', ' ') ?> €
</td>
<td class="prov-date-cell">
<?= formatDate($p['expected_date']) ?>
</td>
<td class="text-right" style="padding-right: 15px;">
<button class="btn-icon-action delete btn-safe-click" title="<?= tr('btn_delete') ?>" onclick="deleteProvision(<?= $p['id'] ?>)">
🗑️
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<div id="optimizeModal" class="pf-modal">
<div class="pf-modal-content provisions-modal-content">
<div class="pf-modal-header">
<h3 class="pf-modal-title">🧠 <?= tr('optimization_assistant_title') ?></h3>
<button type="button" class="pf-modal-close" onclick="closeOptimizeModal()">&times;</button>
</div>
<form id="form-optimize" data-action="/modules/budget/includes/api/optimize-cashflow.php">
<div id="dynamic-savings-inputs">
<?php if (empty($configs)): ?>
<p class="text-danger font-bold pf-muted-tiny"><?= tr('budget_opti_no_config') ?></p>
<input type="number" step="0.01" min="0" name="savings[Global]" class="pf-input no-spinners" required>
<?php else: ?>
<?php foreach ($configs as $person => $ecoBase):
$debt = $debts[$person] ?? 0;
?>
<div class="pf-form-group provision-person-card">
<label class="pf-label provision-person-header">
<strong class="text-main"><?= htmlspecialchars($person) ?></strong>
<span class="pf-muted-note">
<?php if ($debt > 0): ?>
<span class="text-danger font-bold">(Dette en attente : <?= (float)$debt ?> €)</span>
<?php endif; ?>
</span>
</label>
<div class="provision-input-group">
<input type="number" step="0.01" min="0" name="savings[<?= htmlspecialchars($person) ?>]" class="pf-input no-spinners input-amount-success" value="<?= (float)$ecoBase ?>" required>
<span class="currency-symbol">€</span>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<button type="submit" class="pf-btn pf-btn-primary btn-block">
<?= tr('calculate') ?>
</button>
</form>
<hr class="pf-divider">
<div id="optimization-results">
<p class="optimization-hint">
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
</p>
</div>
</div>
</div>
<script>
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
'btn_delete': <?= json_encode(tr('btn_delete')) ?>,
'error_generic': <?= json_encode(tr('error_generic')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>
};
// --- 2. GESTION DU FORMULAIRE D'AJOUT ---
const formAdd = document.getElementById('form-add-provision');
if (formAdd) {
formAdd.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerText;
submitBtn.innerText = '⏳';
submitBtn.disabled = true;
try {
const result = await pachaFetch(this.getAttribute('data-action'), {
method: 'POST',
body: new FormData(this)
});
if (result.success) {
window.location.reload();
} else {
alert(result.message || window.I18N['error_generic']);
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
} catch (error) {
alert(window.I18N['bud_err_tech']);
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
});
}
// --- 3. GESTION DE LA SUPPRESSION ---
async function deleteProvision(id) {
if (!confirm(window.I18N['btn_delete'] + ' ?')) return;
try {
const result = await pachaFetch('/modules/budget/includes/api/delete-provision.php', {
method: "POST",
body: new URLSearchParams({ id: id })
});
if (result.success) {
window.location.reload();
} else {
alert(result.message);
}
} catch(err) {
alert(window.I18N['bud_err_tech']);
}
}
// --- 4. CONTROL DE LA MODALE D'OPTIMISATION ---
function openOptimizeModal() {
document.getElementById('optimization-results').innerHTML = `
<p class="optimization-hint">
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
</p>`;
document.getElementById('optimizeModal').classList.add('open');
document.body.classList.add('no-scroll');
}
function closeOptimizeModal() {
document.getElementById('optimizeModal').classList.remove('open');
document.body.classList.remove('no-scroll');
}
window.onclick = function(event) {
const modal = document.getElementById('optimizeModal');
if (event.target == modal) {
closeOptimizeModal();
}
}
// --- 5. EXECUTION ET SUBMIT DE L'ALGORITHME (formOptimize) ---
const formOptimize = document.getElementById('form-optimize');
if (formOptimize) {
formOptimize.addEventListener('submit', async function(e) {
e.preventDefault();
const resultsContainer = document.getElementById('optimization-results');
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.disabled = true;
resultsContainer.innerHTML = '<div class="optimization-hint">⏳ Analyse des quinzaines bancaires...</div>';
try {
const result = await pachaFetch(this.getAttribute('data-action'), {
method: 'POST',
body: new FormData(this)
});
if (result.success) {
resultsContainer.innerHTML = result.html;
} else {
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${result.message}</p>`;
}
} catch (error) {
console.error("Erreur d'optimisation :", error);
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${window.I18N['bud_err_tech']}</p>`;
} finally {
submitBtn.disabled = false;
}
});
}
</script>
+205 -243
View File
@@ -1,46 +1,60 @@
<?php <?php
// modules/budget/views/recap.php // modules/budget/views/recap.php
// 1. Récupération des Items du Budget // 1. Récupération des Catégories dynamiques
$stmt = $pdo->query("SELECT * FROM pf_budget_items ORDER BY category DESC, sort_order ASC, name ASC"); $stmtCats = $pdo->query("SELECT code, label, icon, type FROM pf_budget_categories ORDER BY type DESC, label ASC");
$items = $stmt->fetchAll(); $dbCategories = $stmtCats->fetchAll(PDO::FETCH_ASSOC);
// 2. Déterminer quel est le mois de gestion "ouvert" par défaut // 2. Récupération des Salaires/Mensualités configurés (Revenus automatiques)
$currentYear = isset($_GET['y']) ? (int)$_GET['y'] : date('Y');
$stmtSalaries = $pdo->prepare("SELECT id, person, mensualite FROM pf_salary_config WHERE year = ?");
$stmtSalaries->execute([$currentYear]);
$salaries = $stmtSalaries->fetchAll(PDO::FETCH_ASSOC);
// 3. Récupération des Items du Budget (Charges Fixes et Estimations)
$stmt = $pdo->query("SELECT * FROM pf_budget_items WHERE category != 'SAVINGS' ORDER BY is_estimate ASC, sort_order ASC, name ASC");
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 4. Gestion du mois actif
$stmtActive = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'active_gestion_month' LIMIT 1"); $stmtActive = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'active_gestion_month' LIMIT 1");
$defaultActiveMonth = $stmtActive->fetchColumn(); $defaultActiveMonth = $stmtActive->fetchColumn() ?: date('Y-m-01');
if (!$defaultActiveMonth) {
$defaultActiveMonth = date('Y-m-01');
}
// 3. Assigner le mois et l'année en fonction du mois ouvert
$currentMonth = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m', strtotime($defaultActiveMonth)); $currentMonth = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m', strtotime($defaultActiveMonth));
$currentYear = isset($_GET['y']) ? (int)$_GET['y'] : date('Y', strtotime($defaultActiveMonth));
$viewMonthDate = "$currentYear-$currentMonth-01"; $viewMonthDate = "$currentYear-$currentMonth-01";
$sqlReal = "SELECT budget_item_id, SUM(amount) as total_real // 5. Récupération optimisée et unifiée du Réel
// On récupère tout en une seule requête pour éviter les décalages entre ID et Catégorie
$stmtReal = $pdo->prepare("
SELECT budget_item_id, category, SUM(amount) as total_real
FROM pf_expenses FROM pf_expenses
WHERE gestion_month = ? AND budget_item_id IS NOT NULL WHERE gestion_month = ?
GROUP BY budget_item_id"; GROUP BY budget_item_id, category
$stmtReal = $pdo->prepare($sqlReal); ");
$stmtReal->execute([$viewMonthDate]); $stmtReal->execute([$viewMonthDate]);
$realTotals = $stmtReal->fetchAll(PDO::FETCH_KEY_PAIR); // Retourne un tableau [id => total] $allExpenses = $stmtReal->fetchAll(PDO::FETCH_ASSOC);
$sqlCatReal = "SELECT category, SUM(amount) as total_real // On initialise les tableaux de correspondance
FROM pf_expenses $realTotalsById = []; // Pour les lignes liées par ID
WHERE gestion_month = ? AND budget_item_id IS NULL $realTotalsByCat = []; // Pour les lignes orphelines (NULL) liées par Catégorie
GROUP BY category";
$stmtCatReal = $pdo->prepare($sqlCatReal);
$stmtCatReal->execute([$viewMonthDate]);
$catTotals = $stmtCatReal->fetchAll(PDO::FETCH_KEY_PAIR);
$stmtLabels = $pdo->prepare("SELECT label, amount FROM pf_expenses WHERE gestion_month = ? AND budget_item_id IS NULL"); foreach ($allExpenses as $row) {
if (!empty($row['budget_item_id'])) {
$realTotalsById[$row['budget_item_id']] = (float)$row['total_real'];
} else {
$realTotalsByCat[$row['category']] = (float)$row['total_real'];
}
}
$stmtSalariesReal = $pdo->prepare("SELECT salary_id, SUM(amount) as total_salary FROM pf_expenses WHERE gestion_month = ? AND salary_id IS NOT NULL GROUP BY salary_id");
$stmtSalariesReal->execute([$viewMonthDate]);
$realTotalsBySalary = $stmtSalariesReal->fetchAll(PDO::FETCH_KEY_PAIR);
// Récupération des détails pour le mapping par mots-clés (si besoin de précision)
$stmtLabels = $pdo->prepare("SELECT label, amount, category FROM pf_expenses WHERE gestion_month = ? AND budget_item_id IS NULL");
$stmtLabels->execute([$viewMonthDate]); $stmtLabels->execute([$viewMonthDate]);
$unlinkedExpenses = $stmtLabels->fetchAll(PDO::FETCH_ASSOC); $unlinkedExpenses = $stmtLabels->fetchAll(PDO::FETCH_ASSOC);
$moisFr = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; // Gestion des libellés et totaux
$monthTranslationKey = 'month_' . str_pad((int)$currentMonth, 2, '0', STR_PAD_LEFT); $currentMonthName = tr('month_' . str_pad((int)$currentMonth, 2, '0', STR_PAD_LEFT)) . ' ' . $currentYear;
$currentMonthName = tr($monthTranslationKey) . ' ' . $currentYear;
$totalDepenses = 0; $totalDepenses = 0;
$totalRevenus = 0; $totalRevenus = 0;
?> ?>
@@ -64,35 +78,69 @@ $totalRevenus = 0;
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($items as $item): <?php foreach ($salaries as $salary):
// --- 1. CALCUL DES TOTAUX PRÉVUS --- $mensualite = (float)$salary['mensualite'];
$totalRevenus += $mensualite;
// Calcul du réel perçu
$realPerçu = isset($realTotalsBySalary[$salary['id']]) ? (float)$realTotalsBySalary[$salary['id']] : 0;
$gap = $realPerçu - $mensualite;
$isAutoChecked = ($realPerçu >= ($mensualite - 0.10));
?>
<tr class="row-income" style="border-bottom:1px solid var(--border-light); background:#f0fdf4;">
<td style="padding:15px;">
<strong>Apport <?= htmlspecialchars($salary['person']) ?></strong>
<span title="Le montant cible est défini dans les paramètres" style="font-size:0.8rem; cursor:help;">⚙️</span>
</td>
<td class="cell-amount" style="font-weight:600; padding:15px; color:#10b981;">
+ <?= number_format($mensualite, 2, ',', ' ') ?> €
<?php if ($realPerçu > 0): ?>
<?php if ($gap > 0.05): ?>
<div style="font-size:0.75rem; color:#10b981; font-weight:bold;">Bonus : +<?= number_format($gap, 2, ',', ' ') ?> €</div>
<?php elseif ($gap < -0.05): ?>
<div style="font-size:0.75rem; color:#f59e0b;">Manque : <?= number_format(abs($gap), 2, ',', ' ') ?> €</div>
<?php else: ?>
<div style="font-size:0.75rem; color:#10b981;">Atteint ✓</div>
<?php endif; ?>
<?php endif; ?>
</td>
<td style="padding:15px;">
<span class="badge-type" style="background:#dcfce7; color:#16a34a; padding:4px 8px; border-radius:12px; font-size:0.8rem; font-weight:600;">Revenu Mensuel</span>
</td>
<td style="padding:15px; color:#64748b; font-weight:bold;">-</td>
<td style="padding:15px;">
<?php if ($isAutoChecked): ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#dcfce7; color:#16a34a; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #bbf7d0;"><span>✓</span> Reçu</div>
<?php elseif ($realPerçu > 0): ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#fffbeb; color:#d97706; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #fde68a;"><span>⏳</span> Partiel</div>
<?php else: ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#f8fafc; color:#94a3b8; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #e2e8f0;"><span>○</span> En attente</div>
<?php endif; ?>
</td>
<td style="padding:15px; text-align:right;">
<small style="color:#94a3b8;">Via Paramètres</small>
</td>
</tr>
<?php endforeach; ?>
<?php
foreach ($items as $item):
// 1. Calculs de base
$targetAbs = abs((float)$item['amount']); $targetAbs = abs((float)$item['amount']);
$amountToAdd = ($item['type'] === 'Annuel') ? $targetAbs / 12 : $targetAbs; $amountToAdd = ($item['type'] === 'Annuel') ? $targetAbs / 12 : $targetAbs;
$totalDepenses += $amountToAdd;
if ($item['category'] === 'income') $totalRevenus += $amountToAdd; // 2. RÉINITIALISATION STRICTE
else $totalDepenses += $amountToAdd;
// --- 2. CALCUL DU RÉEL (Logique optimisée) ---
$realSum = 0; $realSum = 0;
$hasMatchingExpense = false; $hasMatchingExpense = false;
// A. Correspondance directe par ID // A. Correspondance par ID direct (Le lien dur)
if (isset($realTotals[$item['id']])) { if (isset($realTotalsById[$item['id']])) {
$realSum = $realTotals[$item['id']]; $realSum = (float)$realTotalsById[$item['id']];
$hasMatchingExpense = true; $hasMatchingExpense = true;
} }
// B. Correspondance par catégorie système (École, Essence, FMCG) // B. Correspondance par mots-clés bancaires (Le lien intelligent pour les orphelines)
else {
$catKey = null;
if (trim($item['name']) === 'Estimacio escola') $catKey = 'School';
elseif (trim($item['name']) === 'Estimation gasolina') $catKey = 'Essence';
elseif (trim($item['name']) === 'Estimacio F&B & beauty') $catKey = 'FMCG';
if ($catKey && isset($catTotals[$catKey])) {
$realSum = $catTotals[$catKey];
$hasMatchingExpense = true;
}
// C. Correspondance par mots-clés (seulement sur les dépenses non liées)
elseif (!empty($item['mapping_keywords'])) { elseif (!empty($item['mapping_keywords'])) {
$keywords = array_map('trim', explode(',', $item['mapping_keywords'])); $keywords = array_map('trim', explode(',', $item['mapping_keywords']));
foreach ($unlinkedExpenses as $uexp) { foreach ($unlinkedExpenses as $uexp) {
@@ -100,88 +148,78 @@ $totalRevenus = 0;
if (!empty($kw) && stripos($uexp['label'], $kw) !== false) { if (!empty($kw) && stripos($uexp['label'], $kw) !== false) {
$realSum += (float)$uexp['amount']; $realSum += (float)$uexp['amount'];
$hasMatchingExpense = true; $hasMatchingExpense = true;
break; break; // On a trouvé un mot clé sur cette dépense, on passe à l'orpheline suivante
}
} }
} }
} }
} }
// C. Absorption des orphelines par catégories (Le lien Macro/Micro)
if (!empty($item['category'])) {
$catCodes = array_map('trim', explode(',', $item['category']));
foreach ($catCodes as $cCode) {
// Si la catégorie existe dans les orphelines et n'a pas encore été consommée
if (!empty($cCode) && isset($realTotalsByCat[$cCode]) && $realTotalsByCat[$cCode] != 0) {
$realSum += $realTotalsByCat[$cCode];
$hasMatchingExpense = true;
// On la vide pour ne pas la compter 2 fois si une autre ligne utilise la même catégorie
$realTotalsByCat[$cCode] = 0;
}
}
}
// 3. Finalisation des variables d'affichage
$realAbs = abs($realSum); $realAbs = abs($realSum);
$isAutoChecked = ($hasMatchingExpense && ($realAbs >= ($targetAbs - 0.10))); $isAutoChecked = ($hasMatchingExpense && ($realAbs >= ($targetAbs - 0.10)));
$rowClass = 'row-expense' . ($item['is_estimate'] ? ' row-estimate' : '');
$rowClass = ($item['category'] === 'income') ? 'row-income' : 'row-expense'; ?>
if ($item['is_estimate']) $rowClass .= ' row-estimate';
?>
<tr class="<?= $rowClass ?>" style="border-bottom:1px solid var(--border-light);"> <tr class="<?= $rowClass ?>" style="border-bottom:1px solid var(--border-light);">
<td style="padding:15px;"> <td style="padding:15px;">
<strong><?= htmlspecialchars($item['name']) ?></strong> <strong><?= htmlspecialchars($item['name']) ?></strong>
<?= $item['is_estimate'] ? ' <small style="color:#64748b;">('.tr('bud_est_short').')</small>' : '' ?> <?= $item['is_estimate'] ? ' <small style="color:#64748b;">(Variable)</small>' : '' ?>
<?php if(!empty($item['mapping_keywords'])): ?> <?php if(!empty($item['mapping_keywords'])): ?>
<span title="<?= htmlspecialchars($item['mapping_keywords']) ?>" style="font-size:0.7rem; cursor:help;">🔗</span> <span title="Reconnaissance bancaire : <?= htmlspecialchars($item['mapping_keywords']) ?>" style="font-size:0.7rem; cursor:help;">🔗</span>
<?php endif; ?>
<?php if(!empty($item['reg_month'])): ?>
<div style="font-size:0.75rem; color:#94a3b8; font-style:italic; margin-top:2px;">
📅 <?= sprintf(tr('bud_reg_planned_in'), tr('month_'.str_pad(array_search($item['reg_month'], $moisFr), 2, '0', STR_PAD_LEFT))) ?>
</div>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="cell-amount" style="font-weight:600; padding:15px; color:<?= $item['category']==='income'?'#10b981':'#1e293b' ?>;"> <td class="cell-amount" style="font-weight:600; padding:15px; color:#1e293b;">
<?= number_format($targetAbs, 2, ',', ' ') ?> € - <?= number_format($targetAbs, 2, ',', ' ') ?> €
<?php if ($hasMatchingExpense): ?> <?php if ($hasMatchingExpense): ?>
<?php <?php $gap = $realAbs - $targetAbs;
// Le "Gap" est la différence visuelle.
$gap = $realAbs - $targetAbs;
if ($gap > 0.05): ?> if ($gap > 0.05): ?>
<div style="font-size:0.75rem; color:#ef4444; font-weight:bold;"> <div style="font-size:0.75rem; color:#ef4444; font-weight:bold;">Dépassé : +<?= number_format($gap, 2, ',', ' ') ?> €</div>
<?= $item['category'] === 'income' ? tr('bud_bonus') : tr('bud_overrun') ?> : +<?= number_format($gap, 2, ',', ' ') ?> €
</div>
<?php elseif ($gap < -0.05): ?> <?php elseif ($gap < -0.05): ?>
<div style="font-size:0.75rem; color:#f59e0b; font-weight:normal;"> <div style="font-size:0.75rem; color:#f59e0b;">Reste : <?= number_format(abs($gap), 2, ',', ' ') ?> €</div>
<?= tr('bud_remaining') ?> : <?= number_format(abs($gap), 2, ',', ' ') ?> €
</div>
<?php else: ?> <?php else: ?>
<div style="font-size:0.75rem; color:#10b981; font-weight:normal;"> <div style="font-size:0.75rem; color:#10b981;">Atteint ✓</div>
<?= tr('bud_exact_amount') ?> ✓
</div>
<?php endif; ?> <?php endif; ?>
<?php elseif ($item['type'] === 'Annuel'): ?> <?php elseif ($item['type'] === 'Annuel'): ?>
<div style="font-size:0.75rem; color:#94a3b8; font-weight:normal;"><?= tr('bud_per_month_short') ?> <?= number_format($amountToAdd, 2, ',', ' ') ?>/<?= tr('bud_month_short') ?></div> <div style="font-size:0.75rem; color:#94a3b8;"><?= number_format($amountToAdd, 2, ',', ' ') ?>/mois</div>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="padding:15px;"> <td style="padding:15px;">
<span class="badge-type <?= strtolower($item['type']) ?>" style="background:#e2e8f0; padding:4px 8px; border-radius:12px; font-size:0.8rem; font-weight:600; color:#475569;"> <span class="badge-type" style="background:#e2e8f0; padding:4px 8px; border-radius:12px; font-size:0.8rem; font-weight:600; color:#475569;">
<?= tr('bud_freq_'.strtolower($item['type'])) ?> <?= htmlspecialchars($item['type']) ?>
</span> </span>
</td> </td>
<td style="padding:15px; color:#64748b; font-weight:bold;"><?= $item['payment_day'] ? $item['payment_day'] : '-' ?></td> <td style="padding:15px; color:#64748b; font-weight:bold;"><?= $item['payment_day'] ?: '-' ?></td>
<td style="padding:15px;"> <td style="padding:15px;">
<?php if ($isAutoChecked): ?> <?php if ($isAutoChecked): ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#f0fdf4; color:#16a34a; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #bbf7d0;"> <div style="display:inline-flex; align-items:center; gap:5px; background:#f0fdf4; color:#16a34a; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #bbf7d0;"><span>✓</span> Validé</div>
<span>✓</span> <?= tr('bud_state_validated') ?>
</div>
<?php elseif($hasMatchingExpense): ?> <?php elseif($hasMatchingExpense): ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#fffbeb; color:#d97706; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #fde68a;"> <div style="display:inline-flex; align-items:center; gap:5px; background:#fffbeb; color:#d97706; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #fde68a;"><span>⏳</span> Partiel</div>
<span>⏳</span> <?= tr('bud_state_partial') ?>
</div>
<?php else: ?> <?php else: ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#f8fafc; color:#94a3b8; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #e2e8f0;"> <div style="display:inline-flex; align-items:center; gap:5px; background:#f8fafc; color:#94a3b8; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #e2e8f0;"><span>○</span> En attente</div>
<span>○</span> <?= tr('bud_state_waiting') ?>
</div>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="padding:15px; text-align:right;"> <td style="padding:15px; text-align:right;">
<div class="action-buttons" style="display:flex; gap:5px; justify-content:flex-end;"> <div class="action-buttons" style="display:flex; gap:5px; justify-content:flex-end;">
<button class="btn-icon-action edit" onclick='editRecapItem(<?= htmlspecialchars(json_encode($item), ENT_QUOTES, 'UTF-8') ?>)' title="<?= tr('edit') ?>">✏️</button> <button class="btn-icon-action edit" onclick='editRecapItem(<?= htmlspecialchars(json_encode($item), ENT_QUOTES, 'UTF-8') ?>)' title="Modifier">✏️</button>
<button class="btn-icon-action delete" onclick="deleteRecapItem(<?= $item['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button> <button class="btn-icon-action delete" onclick="deleteRecapItem(<?= $item['id'] ?>)" title="Supprimer">🗑️</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -189,34 +227,30 @@ $totalRevenus = 0;
</tbody> </tbody>
<tfoot style="background:#f8fafc;"> <tfoot style="background:#f8fafc;">
<tr> <tr>
<td colspan="1" style="padding:15px;"><strong><?= tr('bud_total_income_smoothed') ?></strong></td> <td colspan="1" style="padding:15px;"><strong>Total Revenus Lissés</strong></td>
<td colspan="5" style="padding:15px; color:#10b981;"><strong>+ <?= number_format($totalRevenus, 2, ',', ' ') ?> €</strong></td> <td colspan="5" style="padding:15px; color:#10b981;"><strong>+ <?= number_format($totalRevenus, 2, ',', ' ') ?> €</strong></td>
</tr> </tr>
<tr> <tr>
<td colspan="1" style="padding:15px;"><strong><?= tr('bud_total_expenses_smoothed') ?></strong></td> <td colspan="1" style="padding:15px;"><strong>Totalpenses & Estimations</strong></td>
<td colspan="5" style="padding:15px; color:#ef4444;"><strong>- <?= number_format($totalDepenses, 2, ',', ' ') ?> €</strong></td> <td colspan="5" style="padding:15px; color:#ef4444;"><strong>- <?= number_format($totalDepenses, 2, ',', ' ') ?> €</strong></td>
</tr> </tr>
<tr style="border-top: 2px solid #e2e8f0; background: white;"> <tr style="border-top: 2px solid #e2e8f0; background: white;">
<td colspan="1" style="padding:15px; font-size:1.1rem;"><strong><?= tr('bud_theoretical_balance_recap') ?></strong></td> <td colspan="1" style="padding:15px; font-size:1.1rem;"><strong>Reste à Vivre (Équilibre)</strong></td>
<?php $balance = $totalRevenus - $totalDepenses; ?> <?php $balance = $totalRevenus - $totalDepenses; ?>
<td colspan="5" style="padding:15px; font-size: 1.3em;" class="<?= $balance >= 0 ? 'text-success' : 'text-danger' ?>"> <td colspan="5" style="padding:15px; font-size: 1.3em;">
<strong style="color:<?= $balance >= 0 ? '#10b981' : '#ef4444' ?>;"><?= number_format($balance, 2, ',', ' ') ?> € / <?= tr('bud_month_short') ?></strong> <strong style="color:<?= $balance >= 0 ? '#10b981' : '#ef4444' ?>;"><?= number_format($balance, 2, ',', ' ') ?> € / mois</strong>
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
<div class="budget-note" style="margin-top:15px; font-size:0.85rem; color:#64748b;">
<p>* <?= tr('bud_recap_footer_note') ?></p>
</div>
</div> </div>
<div id="budgetRecapModal" class="pf-modal" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(15, 23, 42, 0.6); backdrop-filter:blur(4px); align-items:center; justify-content:center;"> <div id="budgetRecapModal" class="pf-modal" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(15, 23, 42, 0.6); backdrop-filter:blur(4px); align-items:center; justify-content:center;">
<div class="pf-modal-content" style="background:white; width:95%; max-width:500px; border-radius:20px; box-shadow:0 20px 25px -5px rgba(0,0,0,0.1); padding:30px; position:relative;"> <div class="pf-modal-content" style="background:white; width:95%; max-width:500px; border-radius:20px; box-shadow:0 20px 25px -5px rgba(0,0,0,0.1); padding:30px; position:relative;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 id="recapModalTitle" style="margin:0; font-size:1.2rem; color:#1e293b;"><?= tr('bud_recap_modal_add') ?></h3> <h3 id="recapModalTitle" style="margin:0; font-size:1.2rem; color:#1e293b;">Ajouter une ligne</h3>
<button type="button" onclick="closeRecapModal()" style="border:none; background:none; font-size:1.8rem; cursor:pointer; color:#64748b; line-height:1;">&times;</button> <button type="button" onclick="closeRecapModal()" style="border:none; background:none; font-size:1.8rem; cursor:pointer; color:#64748b; line-height:1;">&times;</button>
</div> </div>
@@ -225,90 +259,66 @@ $totalRevenus = 0;
<input type="hidden" name="id" id="item_id"> <input type="hidden" name="id" id="item_id">
<div class="form-group" style="margin-bottom:15px;"> <div class="form-group" style="margin-bottom:15px;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_name') ?></label> <label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Nom de la ligne</label>
<input type="text" name="name" id="item_name" required class="pf-input" placeholder="<?= tr('bud_ph_item_name') ?>" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;"> <input type="text" name="name" id="item_name" required class="pf-input" placeholder="ex: Assurance Auto, Estimation Courses..." style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
</div>
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_keywords') ?></label>
<input type="text" name="mapping_keywords" id="item_keywords" class="pf-input" placeholder="<?= tr('bud_ph_keywords') ?>" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:#f0f9ff; border-color:#bae6fd;">
<small style="color:#64748b; font-size:0.75rem;"><?= tr('bud_help_keywords') ?></small>
</div> </div>
<div style="display:flex; gap:15px; margin-bottom:15px;"> <div style="display:flex; gap:15px; margin-bottom:15px;">
<div style="flex:1;"> <div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_amount_eur') ?></label> <label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Montant Prévu (€)</label>
<input type="number" step="0.01" name="amount" id="item_amount" required class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;"> <input type="number" step="0.01" name="amount" id="item_amount" required class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
</div> </div>
<div style="flex:1;"> <div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_day') ?></label> <label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Type de Dépense</label>
<input type="number" min="1" max="31" name="payment_day" id="item_day" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;"> <select name="is_estimate" id="item_is_estimate" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
<option value="0">Fixe (Facture, Prélèvement)</option>
<option value="1">Variable (Estimation, Enveloppe)</option>
</select>
</div> </div>
</div> </div>
<div style="display:flex; gap:15px; margin-bottom:15px;"> <div style="display:flex; gap:15px; margin-bottom:15px;">
<div style="flex:1;"> <div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_category') ?></label> <label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">
<select name="category" id="item_category" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;"> Catégorie(s) Cible(s) <small style="font-weight:normal;">(Maintenez Ctrl/Cmd pour choix multiple)</small>
<option value="expense"><?= tr('bud_cat_expense') ?></option> </label>
<option value="income"><?= tr('bud_cat_income') ?></option> <select name="category_multi[]" id="item_category" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white; min-height: 120px;" multiple required>
<?php foreach ($dbCategories as $c): ?>
<?php if(strtolower($c['type']) === 'expense'): ?>
<option value="<?= htmlspecialchars($c['code']) ?>">
<?= htmlspecialchars($c['icon'] . ' ' . $c['label']) ?>
</option>
<?php endif; ?>
<?php endforeach; ?>
</select> </select>
</div> </div>
<div style="flex:1;"> <div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_frequency') ?></label> <label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Fréquence</label>
<select name="type" id="item_type" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;"> <select name="type" id="item_type" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
<option value="Mensuel"><?= tr('bud_freq_mensuel') ?></option> <option value="Mensuel">Mensuel</option>
<option value="Annuel"><?= tr('bud_freq_annuel') ?></option> <option value="Annuel">Annuel</option>
</select> </select>
</div> </div>
</div> </div>
<div style="display:flex; gap:15px; margin-bottom:25px;"> <div class="form-group" style="margin-bottom:15px;">
<div style="flex:1;"> <label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Mots-clés (Reconnaissance Bancaire)</label>
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_amount_type') ?></label> <input type="text" name="mapping_keywords" id="item_keywords" class="pf-input" placeholder="ex: NETFLIX, DOMOFINANCE..." style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:#f0f9ff; border-color:#bae6fd;">
<select name="is_estimate" id="item_is_estimate" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;"> <small style="color:#64748b; font-size:0.75rem;">Optionnel. Permet au système de lier automatiquement un import CSV à cette ligne.</small>
<option value="0"><?= tr('bud_type_fixed') ?></option>
<option value="1"><?= tr('bud_type_variable') ?></option>
</select>
</div>
<div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_regularization') ?></label>
<select name="reg_month" id="item_reg_month" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
<option value=""><?= tr('bud_reg_none') ?></option>
<?php
foreach($moisFr as $index => $m) {
if($index == 0) continue;
echo "<option value='$m'>" . tr('month_'.str_pad($index, 2, '0', STR_PAD_LEFT)) . "</option>";
}
?>
</select>
</div>
</div> </div>
<div style="display:flex; justify-content:flex-end; gap:10px;"> <div style="display:flex; justify-content:flex-end; gap:10px; margin-top: 25px;">
<button type="button" onclick="closeRecapModal()" class="pf-btn btn-secondary" style="width:auto; margin:0; background:#f1f5f9; color:#475569; border:none; padding:10px 20px; border-radius:8px; font-weight:600; cursor:pointer;"><?= tr('btn_cancel') ?></button> <button type="button" onclick="closeRecapModal()" class="pf-btn btn-secondary" style="width:auto; margin:0; background:#f1f5f9; color:#475569; border:none; padding:10px 20px; border-radius:8px; font-weight:600; cursor:pointer;">Annuler</button>
<button type="submit" class="pf-btn" style="width:auto; margin:0; background:#2563eb; color:white; border:none; padding:10px 20px; border-radius:8px; font-weight:600; cursor:pointer;"><?= tr('btn_save') ?></button> <button type="submit" class="pf-btn" style="width:auto; margin:0; background:#2563eb; color:white; border:none; padding:10px 20px; border-radius:8px; font-weight:600; cursor:pointer;">Enregistrer</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<script> <script>
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
'bud_recap_modal_add': <?= json_encode(tr('bud_recap_modal_add')) ?>,
'bud_recap_modal_edit': <?= json_encode(tr('bud_recap_modal_edit')) ?>,
'bud_recap_confirm_delete': <?= json_encode(tr('bud_recap_confirm_delete')) ?>,
'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
'bud_saving': <?= json_encode(tr('bud_sav_saving') ?? 'Sauvegarde...') ?>
};
function openRecapModal(mode) { function openRecapModal(mode) {
if (mode === "add") { if (mode === "add") {
document.getElementById("recapModalTitle").innerText = window.I18N['bud_recap_modal_add'] || 'Ajouter'; document.getElementById("recapModalTitle").innerText = 'Ajouter une ligne';
document.getElementById("item_id").value = ""; document.getElementById("item_id").value = "";
document.getElementById("recapForm").reset(); document.getElementById("recapForm").reset();
} }
@@ -322,113 +332,65 @@ function closeRecapModal() {
} }
window.onclick = function(event) { window.onclick = function(event) {
const modal = document.getElementById('budgetRecapModal'); if (event.target == document.getElementById('budgetRecapModal')) closeRecapModal();
if (event.target == modal) {
closeRecapModal();
}
} }
function editRecapItem(item) { function editRecapItem(item) {
const data = typeof item === "string" ? JSON.parse(item) : item; const data = typeof item === "string" ? JSON.parse(item) : item;
document.getElementById("recapModalTitle").innerText = "Editer : " + data.name;
document.getElementById("recapModalTitle").innerText = (window.I18N['bud_recap_modal_edit'] || 'Editer') + " : " + data.name;
document.getElementById("item_id").value = data.id; document.getElementById("item_id").value = data.id;
document.getElementById("item_name").value = data.name; document.getElementById("item_name").value = data.name;
document.getElementById("item_keywords").value = data.mapping_keywords || ''; document.getElementById("item_keywords").value = data.mapping_keywords || '';
document.getElementById("item_amount").value = Math.abs(data.amount); document.getElementById("item_amount").value = Math.abs(data.amount);
document.getElementById("item_category").value = data.category; const selectedCats = data.category ? data.category.split(',') : [];
const selectEl = document.getElementById("item_category");
Array.from(selectEl.options).forEach(opt => {
opt.selected = selectedCats.includes(opt.value);
});
document.getElementById("item_type").value = data.type; document.getElementById("item_type").value = data.type;
document.getElementById("item_day").value = data.payment_day;
document.getElementById("item_reg_month").value = data.reg_month || "";
document.getElementById("item_is_estimate").value = data.is_estimate; document.getElementById("item_is_estimate").value = data.is_estimate;
document.getElementById("budgetRecapModal").style.display = "flex"; document.getElementById("budgetRecapModal").style.display = "flex";
document.body.classList.add('no-scroll'); document.body.classList.add('no-scroll');
} }
// --- 2. INTERCEPTION ASYNCHRONE DU FORMULAIRE --- document.getElementById('recapForm').addEventListener('submit', async function(e) {
const recapForm = document.getElementById('recapForm');
if (recapForm) {
recapForm.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
const btn = this.querySelector('button[type="submit"]');
const submitBtn = this.querySelector('button[type="submit"]'); btn.disabled = true; btn.innerText = '⏳ ...';
const originalText = submitBtn.innerText;
submitBtn.innerText = window.I18N['bud_saving'] || '⏳ ...';
submitBtn.disabled = true;
const formData = new FormData(this); const formData = new FormData(this);
const cats = formData.getAll('category_multi[]');
formData.delete('category_multi[]');
formData.append('category', cats.join(','));
formData.append('ajax', '1'); formData.append('ajax', '1');
const url = this.getAttribute('action');
// 💡 Utilisation sécurisée de getAttribute
const actionUrl = this.getAttribute('action');
const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl;
try { try {
const response = await fetch(finalUrl, { const res = await fetch(url.startsWith('/') ? url.substring(1) : url, { method: 'POST', body: formData });
method: 'POST', const data = await res.json();
body: formData if (data.success) window.location.reload();
}); else alert("Erreur : " + data.error);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
// 💡 Lecture robuste (anti-Warnings PHP)
const textResult = await response.text();
try {
const result = JSON.parse(textResult);
if (result.success) {
closeRecapModal();
window.location.reload();
} else {
alert((window.I18N['bud_err_tech'] || 'Erreur') + " : " + (result.error || "Inconnue"));
}
} catch (jsonError) {
console.error("Réponse non-JSON :", textResult);
alert("Le serveur a renvoyé une erreur PHP. Regarde la console (F12).");
}
} catch (error) {
console.error("Erreur Fetch:", error);
alert(window.I18N['bud_err_tech'] || 'Erreur technique');
} finally {
submitBtn.disabled = false;
submitBtn.innerText = originalText;
}
});
}
// --- 3. SUPPRESSION ASYNCHRONE ---
async function deleteRecapItem(id) {
if (!confirm(window.I18N['bud_recap_confirm_delete'] || "Confirmer la suppression ?")) return;
const formData = new FormData();
formData.append("action", "delete");
formData.append("id", id);
formData.append("ajax", "1"); // Signale à l'API qu'on attend du JSON
try {
const response = await fetch("modules/budget/includes/api/manage-item.php", {
method: "POST",
body: formData,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const textResult = await response.text();
try {
const result = JSON.parse(textResult);
// On s'assure que si l'API ne renvoie pas success, on le signale
if (result.success !== false) {
window.location.reload();
} else {
alert((window.I18N['bud_err_delete'] || 'Erreur de suppression') + " : " + (result.error || ""));
}
} catch (jsonErr) {
console.error("Réponse non-JSON lors de la suppression :", textResult);
window.location.reload(); // Fallback si l'API redirige au lieu de répondre en JSON
}
} catch (err) { } catch (err) {
console.error("Erreur réseau Suppression:", err); alert("Erreur technique lors de la sauvegarde.");
alert(window.I18N['bud_err_delete'] || 'Erreur réseau'); } finally {
btn.disabled = false; btn.innerText = 'Enregistrer';
}
});
async function deleteRecapItem(id) {
if (!confirm("Confirmer la suppression ?")) return;
const formData = new FormData();
formData.append("action", "delete"); formData.append("id", id); formData.append("ajax", "1");
try {
const res = await fetch("modules/budget/includes/api/manage-item.php", { method: "POST", body: formData });
const data = await res.json();
if (data.success !== false) window.location.reload();
else alert("Erreur : " + data.error);
} catch (err) {
window.location.reload();
} }
} }
</script> </script>
+523
View File
@@ -0,0 +1,523 @@
<?php
// Sécurité : Ce fichier ne doit pas être appelé directement
if (!defined('CURRENCY')) {
define('CURRENCY', '€');
}
?>
<div class="budget-settings-backdrop" id="modal-budget-settings">
<div class="budget-settings-modal">
<div class="bs-header">
<h3>⚙️ <?= tr('budget_settings_title') ?></h3>
<button class="bs-close" onclick="closeBudgetSettings()">×</button>
</div>
<div class="bs-layout">
<div class="bs-sidebar">
<button class="bs-tab-btn active" onclick="switchBsTab('accounts', this)">
<?= tr('bs_tab_accounts') ?>
<?php if (!$hasAccounts): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
</button>
<button class="bs-tab-btn" onclick="switchBsTab('categories', this)">
<?= tr('bs_tab_categories') ?>
<?php if (!$hasCategories): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
</button>
<button class="bs-tab-btn" onclick="switchBsTab('rules', this)"><?= tr('bs_tab_rules') ?></button>
<button class="bs-tab-btn" onclick="switchBsTab('salaries', this)">
<?= tr('bs_tab_salaries') ?>
<?php if (!$hasSalaries): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
</button>
<button class="bs-tab-btn" onclick="switchBsTab('csv', this)"><?= tr('bs_tab_csv') ?></button>
</div>
<div class="bs-content">
<?php if (!isset($isBudgetSetupOk) || !$isBudgetSetupOk || !$hasBudgetItems): ?>
<div class="bs-onboarding-card">
<h4 class="bs-onboarding-title">
<span>🚀</span> <?= tr('bs_onb_title') ?>
</h4>
<p class="bs-onboarding-desc"><?= tr('bs_onb_desc') ?></p>
<ul class="bs-onboarding-list">
<li>
<strong class="<?= $hasCategories ? 'text-success' : 'text-danger' ?>"><?= $hasCategories ? '✅' : '❌' ?></strong>
<a class="bs-onboarding-link" onclick="switchBsTab('categories', document.querySelectorAll('.bs-tab-btn')[1]); return false;"><?= tr('bs_onb_cat_link') ?></a> : <?= tr('bs_onb_cat_desc') ?>
</li>
<li>
<strong class="<?= $hasAccounts ? 'text-success' : 'text-danger' ?>"><?= $hasAccounts ? '✅' : '❌' ?></strong>
<a class="bs-onboarding-link" onclick="switchBsTab('accounts', document.querySelectorAll('.bs-tab-btn')[0]); return false;"><?= tr('bs_onb_acc_link') ?></a> : <?= tr('bs_onb_acc_desc') ?>
</li>
<li>
<strong class="<?= $hasSalaries ? 'text-success' : 'text-danger' ?>"><?= $hasSalaries ? '✅' : '❌' ?></strong>
<a class="bs-onboarding-link" onclick="switchBsTab('salaries', document.querySelectorAll('.bs-tab-btn')[3]); return false;"><?= tr('bs_onb_sal_link') ?></a> : <?= tr('bs_onb_sal_desc') ?>
</li>
<li style="margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(180, 83, 9, 0.3);">
<strong class="<?= $hasBudgetItems ? 'text-success' : 'text-warning' ?>" style="color: <?= $hasBudgetItems ? '' : '#f59e0b' ?>"><?= $hasBudgetItems ? '✅' : '⏳' ?></strong>
<a href="?tab=recap" onclick="closeBudgetSettings();" class="bs-onboarding-link"><?= tr('bs_onb_items_link') ?></a> : <?= tr('bs_onb_items_desc') ?>
</li>
</ul>
</div>
<?php endif; ?>
<div id="pane-accounts" class="bs-pane active">
<div class="bs-pane-header">
<h4 class="bs-section-title"><?= tr('bs_tab_accounts') ?></h4>
<div class="bs-currency-selector">
<span><?= tr('bs_currency_label') ?> :</span>
<select id="bs-currency-select" class="pf-input bs-currency-select" onchange="updateBudgetCurrency(this)">
<option value="€">EUR (€)</option>
<option value="$">USD ($)</option>
<option value="£">GBP (£)</option>
<option value="CHF">CHF</option>
</select>
</div>
</div>
<div id="bs-list-accounts" class="bs-list-container">⏳ <?= tr('loading') ?></div>
<hr class="bs-divider">
<h5 class="bs-section-subtitle"><?= tr('bs_add_account_title') ?></h5>
<form id="form-add-account" class="bs-form-inline">
<input type="text" name="name" class="pf-input bs-input-flex" placeholder="<?= tr('bs_account_name_ph') ?>" required>
<select name="type" class="pf-input bs-input-fixed">
<option value="checking"><?= tr('bs_type_checking') ?></option>
<option value="savings"><?= tr('bs_type_savings') ?></option>
</select>
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-categories" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_categories') ?></h4>
<div id="bs-list-categories" class="bs-list-container">⏳ <?= tr('loading') ?></div>
<hr class="bs-divider">
<h5 class="bs-section-subtitle"><?= tr('bs_add_category_title') ?></h5>
<form id="form-add-category" class="bs-form-inline align-center">
<input type="text" name="code" class="pf-input bs-input-fixed" placeholder="<?= tr('bs_cat_code_ph') ?>" required>
<input type="text" name="label" class="pf-input bs-input-flex" placeholder="<?= tr('bs_cat_label_ph') ?>" required>
<select name="type" class="pf-input bs-input-fixed">
<option value="Expense"><?= tr('bs_type_expense') ?></option>
<option value="Income"><?= tr('bs_type_income') ?></option>
<option value="Savings"><?= tr('bs_type_savings_cat') ?></option>
</select>
<select name="icon" class="pf-input bs-input-icon">
<option value="📌">📌</option><option value="🛒">🛒</option><option value="🥖">🥖</option>
<option value="🍽️">🍽️</option><option value="⛽">⛽</option><option value="🚗">🚗</option>
<option value="🚆">🚆</option><option value="🏠">🏠</option><option value="⚡">⚡</option>
<option value="💧">💧</option><option value="⚕️">⚕️</option><option value="🎒">🎒</option>
<option value="👕">👕</option><option value="📱">📱</option><option value="🎮">🎮</option>
<option value="✈️">✈️</option><option value="🎁">🎁</option><option value="🐶">🐶</option>
<option value="💵">💵</option><option value="🐷">🐷</option><option value="📈">📈</option>
</select>
<input type="color" name="color" value="#3b82f6" class="bs-input-color">
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-rules" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_rules') ?></h4>
<input type="text" id="input-search-rules" class="bs-rule-search" placeholder="<?= tr('bs_rule_search_ph') ?>" onkeyup="filterRules()">
<div id="bs-list-rules" class="bs-list-container">⏳ <?= tr('loading') ?></div>
<hr class="bs-divider">
<h5 class="bs-section-subtitle"><?= tr('bs_add_rule_title') ?></h5>
<form id="form-add-rule" class="bs-form-inline align-center">
<input type="text" name="keyword" class="pf-input bs-input-flex" placeholder="<?= tr('bs_rule_keyword_ph') ?>" required>
<select name="category" id="select-rule-category" class="pf-input bs-input-fixed" required></select>
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-salaries" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_salaries') ?> (<?= date('Y') ?>)</h4>
<div id="bs-list-salaries" class="bs-list-container">⏳ <?= tr('loading') ?></div>
</div>
<div id="pane-csv" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_csv_title') ?></h4>
<p class="pf-muted-tiny bs-desc"><?= tr('bs_csv_desc') ?></p>
<div id="csv-drop-zone" class="bs-csv-dropzone">
<div class="bs-csv-icon">📥</div>
<h5 class="bs-csv-title"><?= tr('bs_csv_drop_title') ?></h5>
<p class="pf-muted-tiny bs-csv-subtitle"><?= tr('bs_csv_drop_desc') ?></p>
<input type="file" id="csv_file_input" accept=".csv" style="display: none;">
</div>
<div id="csv-preview-container" class="bs-csv-preview"></div>
<h5 class="bs-section-subtitle bordered"><?= tr('bs_csv_settings_title') ?></h5>
<form id="form-csv-mapping" onsubmit="saveCsvMapping(event)">
<div class="bs-grid-2">
<div>
<label class="pf-label"><?= tr('bs_csv_delimiter') ?></label>
<select name="csv_delimiter" id="csv_delimiter" class="pf-input">
<option value=";"><?= tr('bs_csv_delim_semi') ?></option>
<option value=","><?= tr('bs_csv_delim_comma') ?></option>
<option value="\t"><?= tr('bs_csv_delim_tab') ?></option>
</select>
</div>
<div>
<label class="pf-label"><?= tr('bs_csv_date_format') ?></label>
<select name="csv_date_format" id="csv_date_format" class="pf-input">
<option value="d/m/Y">JJ/MM/AAAA</option>
<option value="Y-m-d">AAAA-MM-JJ</option>
</select>
</div>
</div>
<h5 class="bs-section-subtitle">
<?= tr('bs_csv_col_index') ?>
<small class="bs-text-muted-normal"><?= tr('bs_csv_col_index_help') ?></small>
</h5>
<div class="bs-grid-2">
<div>
<label class="pf-label"><?= tr('bs_csv_col_date') ?></label>
<input type="number" min="0" name="csv_col_date" id="csv_col_date" class="pf-input" value="0" required>
</div>
<div>
<label class="pf-label"><?= tr('bs_csv_col_label') ?></label>
<input type="number" min="0" name="csv_col_label" id="csv_col_label" class="pf-input" value="1" required>
</div>
</div>
<div class="bs-card-gray">
<label class="pf-label"><?= tr('bs_csv_amount_mgmt') ?></label>
<select name="csv_amount_type" id="csv_amount_type" class="pf-input bs-input-mb" onchange="toggleCsvAmountCols(this.value)">
<option value="single"><?= tr('bs_csv_amount_single') ?></option>
<option value="split"><?= tr('bs_csv_amount_split') ?></option>
</select>
<div class="bs-grid-2">
<div>
<label class="pf-label" id="lbl_col_debit"><?= tr('bs_csv_col_amount') ?></label>
<input type="number" min="0" name="csv_col_debit" id="csv_col_debit" class="pf-input" value="8" required>
</div>
<div id="wrapper_col_credit" style="display:none;">
<label class="pf-label"><?= tr('bs_csv_col_credit') ?></label>
<input type="number" min="0" name="csv_col_credit" id="csv_col_credit" class="pf-input" value="9">
</div>
</div>
</div>
<div class="bs-input-mb">
<label class="pf-label"><?= tr('bs_csv_col_ref') ?></label>
<input type="number" min="0" name="csv_col_ref" id="csv_col_ref" class="pf-input" value="3">
</div>
<div class="bs-text-right">
<button type="submit" class="btn btn-secondary">💾 <?= tr('btn_save_format') ?></button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tabsContainer = document.querySelector('.budget-tabs-container');
const activeTab = document.querySelector('.tab-item.active');
if (tabsContainer && activeTab) {
if (tabsContainer.scrollWidth > tabsContainer.clientWidth) {
const scrollPos = activeTab.offsetLeft - (tabsContainer.offsetWidth / 2) + (activeTab.offsetWidth / 2);
tabsContainer.scrollTo({ left: scrollPos, behavior: 'smooth' });
}
}
});
function openBudgetSettings() {
document.getElementById('modal-budget-settings').classList.add('show');
document.body.classList.add('no-scroll');
loadBudgetSettingsData();
}
function closeBudgetSettings() {
document.getElementById('modal-budget-settings').classList.remove('show');
document.body.classList.remove('no-scroll');
window.location.reload();
}
document.getElementById('modal-budget-settings')?.addEventListener('click', (e) => {
if (e.target.id === 'modal-budget-settings') closeBudgetSettings();
});
function switchBsTab(tabId, btnEl) {
document.querySelectorAll('.bs-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.bs-pane').forEach(p => p.classList.remove('active'));
btnEl.classList.add('active');
document.getElementById('pane-' + tabId).classList.add('active');
}
async function loadBudgetSettingsData() {
try {
const response = await pachaFetch('/modules/budget/includes/api/settings.php?action=get_all', { method: 'GET' });
if (!response.success) throw new Error(response.error || tr('error_occured'));
renderAccounts(response.data.accounts);
renderCategories(response.data.categories);
populateRuleCategories(response.data.categories);
renderRules(response.data.rules);
renderSalaries(response.data.salaries, response.data.year);
const currencySelect = document.getElementById('bs-currency-select');
if (currencySelect && response.data.currency) currencySelect.value = response.data.currency;
} catch (err) {
console.error("Erreur chargement paramètres :", err);
const errorMsg = `<div class="pf-alert pf-alert--error bs-input-mb">❌ ${tr('error_loading_settings')} : ${err.message}</div>`;
['accounts', 'categories', 'rules', 'salaries'].forEach(id => {
const el = document.getElementById('bs-list-' + id);
if (el) el.innerHTML = errorMsg;
});
}
}
// 1. ONGLET : COMPTES
function renderAccounts(accounts) {
const container = document.getElementById('bs-list-accounts');
if (!container) return;
if (!accounts || accounts.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_accounts')}</em>`;
return;
}
let html = '';
accounts.forEach(acc => {
const typeBadge = acc.account_type === 'checking' ? tr('bs_type_checking') : tr('bs_type_savings');
const defaultBadge = acc.is_default == 1 ? `<span class="bs-badge-default">${tr('bs_badge_default')}</span>` : '';
const ownerText = acc.owner_name ? `${tr('bs_owner')} : ${acc.owner_name}` : tr('bs_shared_account');
html += `
<div class="bs-list-item">
<div>
<strong>${acc.name}</strong> ${defaultBadge}<br>
<small class="text-muted">${typeBadge} · ${ownerText}</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action delete" onclick="deleteAccount(${acc.id})" title="${tr('btn_delete')}">🗑️</button>
</div>
</div>`;
});
container.innerHTML = html;
}
// 2. ONGLET : CATÉGORIES
function renderCategories(categories) {
const container = document.getElementById('bs-list-categories');
if (!container) return;
if (!categories || categories.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_categories')}</em>`;
return;
}
// Tri JS pour respecter l'ordre au chargement de la modale
categories.sort((a, b) => {
if (a.type !== b.type) return a.type === 'Income' ? -1 : 1;
return (a.sort_order || 0) - (b.sort_order || 0);
});
let html = '';
categories.forEach(cat => {
let typeLabel = cat.type === 'Income' ? tr('bs_type_income') : cat.type === 'Expense' ? tr('bs_type_expense') : tr('bs_type_savings_cat');
html += `
<div class="bs-list-item" data-id="${cat.id}" style="border-left: 4px solid ${cat.color || '#ccc'};">
<div>
<span style="font-size:1.2rem; margin-right:8px;">${cat.icon || '📌'}</span>
<strong>${cat.label}</strong> <small class="text-muted">(${cat.code} — ${typeLabel})</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action" 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>
</div>
</div>`;
});
container.innerHTML = html;
}
// Nouvelles fonctions pour réordonner visuellement et sauvegarder
function moveCatUp(btn) {
let row = btn.closest('.bs-list-item');
if (row.previousElementSibling) {
row.parentNode.insertBefore(row, row.previousElementSibling);
saveCategoriesOrder();
}
}
function moveCatDown(btn) {
let row = btn.closest('.bs-list-item');
if (row.nextElementSibling) {
row.parentNode.insertBefore(row.nextElementSibling, row);
saveCategoriesOrder();
}
}
async function saveCategoriesOrder() {
let ids = Array.from(document.querySelectorAll('#bs-list-categories .bs-list-item')).map(el => el.dataset.id);
let fd = new FormData();
fd.append('action', 'save_categories_order');
fd.append('order', JSON.stringify(ids));
try {
await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd });
} catch (err) {
console.error("Erreur de sauvegarde de l'ordre :", err);
}
}
// 3. ONGLET : RÈGLES D'IMPORT
function renderRules(rules) {
const container = document.getElementById('bs-list-rules');
if (!container) return;
if (!rules || rules.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_rules')}</em>`;
return;
}
const groupedRules = {};
rules.forEach(r => {
const catName = r.cat_label || r.category;
if (!groupedRules[catName]) groupedRules[catName] = [];
groupedRules[catName].push(r);
});
let html = '';
for (const [catName, catRules] of Object.entries(groupedRules)) {
html += `
<details class="pf-accordion js-rule-group" data-catname="${catName.toLowerCase()}">
<summary class="pf-accordion-summary bs-accordion-summary">
<span>📁 ${catName}</span>
<span class="bs-accordion-count">${catRules.length}</span>
</summary>
<div class="pf-accordion-content" style="padding: 0.5rem;">
<div class="bs-rule-tags">`;
catRules.forEach(r => {
html += `<div class="bs-rule-tag js-rule-tag" data-keyword="${r.keyword.toLowerCase()}">
${r.keyword}
<button type="button" class="bs-rule-tag-del" onclick="deleteRule(${r.id})" title="${tr('btn_delete')}">×</button>
</div>`;
});
html += ` </div>
</div>
</details>`;
}
container.innerHTML = html;
}
function filterRules() {
const searchVal = document.getElementById('input-search-rules').value.toLowerCase();
document.querySelectorAll('.js-rule-group').forEach(group => {
let hasVisibleTag = false;
const catName = group.getAttribute('data-catname');
group.querySelectorAll('.js-rule-tag').forEach(tag => {
const keyword = tag.getAttribute('data-keyword');
if (keyword.includes(searchVal)) { tag.style.display = 'inline-flex'; hasVisibleTag = true; }
else { tag.style.display = 'none'; }
});
if (catName.includes(searchVal) && searchVal.length > 0) {
group.querySelectorAll('.js-rule-tag').forEach(tag => tag.style.display = 'inline-flex');
hasVisibleTag = true;
}
if (hasVisibleTag) {
group.style.display = 'block';
if (searchVal.length > 0) group.setAttribute('open', '');
} else {
group.style.display = 'none'; group.removeAttribute('open');
}
});
}
// 5. ONGLET : SALAIRES
function renderSalaries(salaries, year) {
const container = document.getElementById('bs-list-salaries');
if (!container) return;
if (!salaries || salaries.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_salaries')} ${year}.</em>`;
return;
}
const currency = '<?= CURRENCY ?>';
let html = `<p class="bs-desc">${tr('bs_salaries_year_desc')} <strong>${year}</strong>.</p>`;
salaries.forEach(s => {
html += `
<div class="bs-list-item" id="salary-item-${s.id}">
<div>
<strong>${s.person}</strong><br>
<small class="text-muted">
${tr('bs_salary_net')} : ${s.salary} ${currency}<br>
${tr('bs_salary_fees')} : ${s.mensualite} ${currency}
</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action edit" data-id="${s.id}" data-person="${s.person}" data-salary="${s.salary}" data-mensualite="${s.mensualite}" onclick="inlineEditSalary(this)" title="${tr('btn_edit')}">✏️</button>
</div>
</div>`;
});
container.innerHTML = html;
}
// --- CRUD ACTIONS ---
async function deleteAccount(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete'))) return; try { const fd = new FormData(); fd.append('action', 'delete_account'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
const formAddAccount = document.getElementById('form-add-account');
if (formAddAccount) formAddAccount.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddAccount.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddAccount); fd.append('action', 'add_account'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddAccount.reset(); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
async function deleteCategory(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete_cat'))) return; try { const fd = new FormData(); fd.append('action', 'delete_category'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
const formAddCategory = document.getElementById('form-add-category');
if (formAddCategory) formAddCategory.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddCategory.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddCategory); fd.append('action', 'add_category'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddCategory.reset(); formAddCategory.querySelector('input[type="color"]').value = "#3b82f6"; await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
function populateRuleCategories(categories) { const select = document.getElementById('select-rule-category'); if (!select) return; select.innerHTML = ''; if (!categories) return; categories.forEach(cat => { const opt = document.createElement('option'); opt.value = cat.code; opt.textContent = `${cat.icon || ''} ${cat.label}`; select.appendChild(opt); }); }
async function deleteRule(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete_rule'))) return; try { const fd = new FormData(); fd.append('action', 'delete_rule'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
const formAddRule = document.getElementById('form-add-rule');
if (formAddRule) formAddRule.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddRule.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddRule); fd.append('action', 'add_rule'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddRule.reset(); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
function inlineEditSalary(btnEl) {
const id = btnEl.dataset.id; const person = btnEl.dataset.person; const salary = btnEl.dataset.salary; const mensualite = btnEl.dataset.mensualite; const currency = '<?= CURRENCY ?>';
document.getElementById(`salary-item-${id}`).innerHTML = `
<form onsubmit="submitInlineSalary(event, ${id})" class="bs-form-inline align-center">
<div class="bs-input-flex"><strong>${person}</strong></div>
<div style="display:flex; align-items:center; gap:4px;"><small class="text-muted">${tr('bs_salary_net')}:</small><input type="number" step="0.01" name="salary" class="pf-input bs-input-fixed" value="${salary}" required> ${currency}</div>
<div style="display:flex; align-items:center; gap:4px;"><small class="text-muted">${tr('bs_salary_fees')}:</small><input type="number" step="0.01" name="mensualite" class="pf-input bs-input-fixed" value="${mensualite}" required> ${currency}</div>
<button type="submit" class="btn btn-secondary">💾</button>
<button type="button" class="btn btn-ghost" onclick="loadBudgetSettingsData()">❌</button>
</form>`;
}
async function submitInlineSalary(event, id) { event.preventDefault(); const form = event.target; const btn = form.querySelector('button'); btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(form); fd.append('action', 'save_salary'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); btn.innerText = '💾'; btn.disabled = false; } }
async function updateBudgetCurrency(selectEl) { try { const fd = new FormData(); fd.append('action', 'save_currency'); fd.append('currency', selectEl.value); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); if (typeof showToast === 'function') { showToast(`${tr('bs_currency_updated')} ✅`); } await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
// CSV
function toggleCsvAmountCols(type) { if (type === 'split') { document.getElementById('wrapper_col_credit').style.display = 'block'; document.getElementById('lbl_col_debit').innerText = tr('bs_csv_col_debit'); } else { document.getElementById('wrapper_col_credit').style.display = 'none'; document.getElementById('lbl_col_debit').innerText = tr('bs_csv_col_amount'); } }
function renderCsvMapping(mapping) { if (!mapping) return; document.getElementById('csv_delimiter').value = mapping.delimiter || ';'; document.getElementById('csv_date_format').value = mapping.date_format || 'd/m/Y'; document.getElementById('csv_col_date').value = mapping.col_date ?? 0; document.getElementById('csv_col_label').value = mapping.col_label ?? 1; document.getElementById('csv_col_ref').value = mapping.col_ref ?? 3; const amountType = mapping.amount_type || 'split'; document.getElementById('csv_amount_type').value = amountType; document.getElementById('csv_col_debit').value = mapping.col_debit ?? 8; document.getElementById('csv_col_credit').value = mapping.col_credit ?? 9; toggleCsvAmountCols(amountType); }
async function saveCsvMapping(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳...'; btn.disabled = true; try { const fd = new FormData(form); fd.append('action', 'save_csv_mapping'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); if (typeof showToast === 'function') showToast(tr('bs_csv_saved')); else alert(tr('bs_csv_saved')); } catch (err) { alert(tr('error_occured') + " : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } }
const dropZone = document.getElementById('csv-drop-zone'); const fileInput = document.getElementById('csv_file_input');
if (dropZone && fileInput) {
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.backgroundColor = 'rgba(0, 123, 255, 0.05)'; dropZone.style.borderColor = 'var(--primary-color, #007bff)'; });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.style.backgroundColor = ''; dropZone.style.borderColor = 'var(--border-light, #ccc)'; });
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.style.backgroundColor = ''; dropZone.style.borderColor = 'var(--border-light, #ccc)'; if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; handleCsvUpload(e.dataTransfer.files[0]); } });
fileInput.addEventListener('change', function() { if (this.files.length) { handleCsvUpload(this.files[0]); } });
}
function handleCsvUpload(file) {
if (!file.name.endsWith('.csv')) { alert(tr('bs_csv_err_type')); return; }
if (typeof showToast === 'function') { showToast(`${tr('bs_csv_file_ok')} : ${file.name}`); }
const reader = new FileReader();
reader.onload = function(e) {
const text = e.target.result; const delimiter = document.getElementById('csv_delimiter').value || ';';
const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
if (lines.length > 0) {
const headers = lines[0].split(delimiter).map(h => h.replace(/^"|"$/g, '').trim());
const sampleRows = [];
for (let i = 1; i < Math.min(lines.length, 3); i++) { sampleRows.push(lines[i].split(delimiter).map(d => d.replace(/^"|"$/g, '').trim())); }
renderCsvTablePreview(headers, sampleRows);
}
};
reader.readAsText(file, 'ISO-8859-1');
}
function renderCsvTablePreview(headers, sampleRows) {
const container = document.getElementById('csv-preview-container');
let html = `<h5 class="bs-section-subtitle">👁️ ${tr('bs_csv_preview_title')}</h5>`;
html += `<div class="bs-csv-table-wrapper"><table class="bs-csv-table"><thead class="bs-csv-thead"><tr>`;
headers.forEach((header, index) => {
const cleanHeader = header || `(${tr('bs_csv_empty_col')})`;
html += `<th class="bs-csv-th"><div class="bs-csv-th-index">N° ${index}</div><div>${cleanHeader}</div></th>`;
});
html += `</tr></thead><tbody>`;
if (sampleRows.length === 0) { html += `<tr><td colspan="${headers.length}" class="bs-csv-td-empty">${tr('bs_csv_no_data')}</td></tr>`; }
else { sampleRows.forEach(row => { html += `<tr class="bs-csv-tr">`; for (let i = 0; i < headers.length; i++) { const cellData = row[i] !== undefined ? row[i] : ''; const displayData = cellData.length > 40 ? cellData.substring(0, 40) + '...' : cellData; html += `<td class="bs-csv-td">${displayData}</td>`; } html += `</tr>`; }); }
html += `</tbody></table></div>`;
container.innerHTML = html; container.style.display = 'block';
}
</script>
+263 -113
View File
@@ -57,19 +57,29 @@ if (isset($_POST['action']) && $_POST['action'] === 'reopen_month') {
exit; exit;
} }
if (isset($_POST['action']) && $_POST['action'] === 'save_import') { if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
$count = 0; $count = 0;
$stmtExp = $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); $stmtExp = $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id, salary_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmtRule = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category) VALUES (?, ?) ON DUPLICATE KEY UPDATE category = VALUES(category)"); $stmtRule = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category, budget_item_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE category = VALUES(category), budget_item_id = VALUES(budget_item_id)");
if (isset($_POST['lines']) && is_array($_POST['lines'])) { if (isset($_POST['lines']) && is_array($_POST['lines'])) {
foreach ($_POST['lines'] as $line) { foreach ($_POST['lines'] as $line) {
if (isset($line['import_check'])) { if (isset($line['import_check'])) {
$cat = $line['cat']; $cat = $line['cat'];
$is_credit = isset($line['is_credit']) ? (int)$line['is_credit'] : 0; $is_credit = isset($line['is_credit']) ? (int)$line['is_credit'] : 0;
$budgetItemId = !empty($line['budget_item_id']) ? (int)$line['budget_item_id'] : null;
// 🔥 NOUVEAU TRAITEMENT DU BUDGET_ITEM_ID
$rawItemId = $line['budget_item_id'] ?? '';
$budgetItemId = null;
$salaryId = null;
if (!empty($rawItemId)) {
if (strpos($rawItemId, 'SAL_') === 0) {
$salaryId = (int)str_replace('SAL_', '', $rawItemId);
} else {
$budgetItemId = (int)$rawItemId;
}
}
$holidayId = !empty($line['holiday_id']) ? (int)$line['holiday_id'] : null; $holidayId = !empty($line['holiday_id']) ? (int)$line['holiday_id'] : null;
$gestionMonthLine = !empty($line['gestion_month']) ? $line['gestion_month'] . '-01' : $viewMonthDate; $gestionMonthLine = !empty($line['gestion_month']) ? $line['gestion_month'] . '-01' : $viewMonthDate;
@@ -80,8 +90,8 @@ if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
$dateToSave = $line['date']; $dateToSave = $line['date'];
try { try {
$stmtExp->execute([$dateToSave, $gestionMonthLine, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId]); $stmtExp->execute([$dateToSave, $gestionMonthLine, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId, $salaryId]);
$stmtRule->execute([$line['label'], $cat]); $stmtRule->execute([$line['label'], $cat, $budgetItemId]);
$count++; $count++;
} catch (Exception $e) { continue; } } catch (Exception $e) { continue; }
} }
@@ -110,7 +120,15 @@ $showPreview = false;
if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) { if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) {
$file = $_FILES['csv_file']['tmp_name']; $file = $_FILES['csv_file']['tmp_name'];
$handle = fopen($file, "r"); $handle = fopen($file, "r");
$rules = []; try { $rules = $pdo->query("SELECT keyword, category FROM pf_import_rules")->fetchAll(PDO::FETCH_KEY_PAIR); } catch(Exception $e){}
$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){} $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, ";", "\"", "\\"); fgetcsv($handle, 1000, ";", "\"", "\\");
@@ -129,18 +147,59 @@ if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) {
$isDuplicate = in_array($uniqueKey, $existingRefs); $isDuplicate = in_array($uniqueKey, $existingRefs);
$suggestedCat = ''; $suggestedCat = '';
foreach ($rules as $kw => $c) { if (stripos($label, $kw) !== false) { $suggestedCat = $c; break; } } $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, '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); fclose($handle);
$showPreview = true; $showPreview = true;
} }
// ============================================================================ // ============================================================================
// 3. RECUPERATION DES DONNEES ET CALCULS // 3. RECUPERATION DES DONNEES ET CALCULS (100% DYNAMIQUE)
// ============================================================================ // ============================================================================
// --- LECTURE DYNAMIQUE DES CATEGORIES ---
$stmtCats = $pdo->query("SELECT * FROM pf_budget_categories ORDER BY type DESC, sort_order ASC, label ASC");
$dbCategories = $stmtCats->fetchAll(PDO::FETCH_ASSOC);
$categoriesConfig = [];
foreach ($dbCategories as $c) {
$catType = ($c['type'] === 'Income') ? 'credit' : 'debit';
$categoriesConfig[$c['code']] = [
'type' => $catType,
'db_type' => $c['type'],
'label' => ($c['icon'] ? $c['icon'] . ' ' : '') . $c['label'],
'budget' => 0, // Sera rempli par les règles
'color' => $c['color'] ?: '#64748b',
'suggestions' => []
];
}
// Fallback "Autres" au cas où la BDD serait vide ou pour les dépenses non classées
if (!isset($categoriesConfig['AUTRES'])) {
$categoriesConfig['AUTRES'] = [
'type'=>'debit', 'db_type'=>'Expense', 'label'=>'📁 Autres / Divers',
'budget'=>0, 'color'=>'#94a3b8', 'suggestions'=>[]
];
}
// Peuplement dynamique des suggestions via les règles existantes
$stmtRules = $pdo->query("SELECT keyword, category FROM pf_import_rules");
while ($rule = $stmtRules->fetch(PDO::FETCH_ASSOC)) {
if (isset($categoriesConfig[$rule['category']])) {
$categoriesConfig[$rule['category']]['suggestions'][] = $rule['keyword'];
}
}
$stmtCheckClose = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?"); $stmtCheckClose = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?");
$stmtCheckClose->execute([$viewMonthDate]); $stmtCheckClose->execute([$viewMonthDate]);
$closureJson = $stmtCheckClose->fetchColumn(); $closureJson = $stmtCheckClose->fetchColumn();
@@ -170,27 +229,44 @@ $allExpenses = $stmt->fetchAll(PDO::FETCH_ASSOC);
$paidItemIds = array_column(array_filter($allExpenses, fn($e) => !empty($e['budget_item_id'])), 'budget_item_id'); $paidItemIds = array_column(array_filter($allExpenses, fn($e) => !empty($e['budget_item_id'])), 'budget_item_id');
$realExpensesLabels = array_column(array_filter($allExpenses, fn($e) => $e['amount'] < 0), 'label'); $realExpensesLabels = array_column(array_filter($allExpenses, fn($e) => $e['amount'] < 0), 'label');
$budget_fmcg = 0; $budget_school = 0; $budget_essence = 0; $budget_frais = 0; $budget_income_prevu = 0; $budget_income_prevu = 0;
$total_income = 0; $total_expenses_prevues = 0; $total_income = 0; $total_expenses_prevues = 0;
$reste_a_venir_calc = 0; $reste_a_venir_calc = 0;
$fixedChargesList = []; $incomeList = []; $pending_charges = []; $fixedChargesList = []; $incomeList = []; $pending_charges = [];
$stmt = $pdo->query("SELECT id, name, amount, type, category, is_estimate, payment_day, mapping_keywords FROM pf_budget_items ORDER BY name ASC"); // ============================================================================
// 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");
$estimatesList = []; // On prépare la liste pour retenir nos estimations multi-catégories
while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) { while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
$absAmount = abs((float)$item['amount']); $absAmount = abs((float)$item['amount']);
$amt = ($item['type'] === 'Annuel') ? $absAmount / 12 : $absAmount; $amt = ($item['type'] === 'Annuel') ? $absAmount / 12 : $absAmount;
$name = trim($item['name']); $name = trim($item['name']);
$catCode = $item['category'];
if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) { $isIncome = (strtoupper($catCode) === 'INCOME');
$fixedChargesList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount];
} if ($isIncome) {
if ($item['category'] === 'income') {
$incomeList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount]; $incomeList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount];
$total_income += $amt; $total_income += $amt;
$budget_income_prevu += $amt; $budget_income_prevu += $amt;
if (!empty($catCode) && isset($categoriesConfig[$catCode])) {
$categoriesConfig[$catCode]['budget'] += $amt;
} else {
$incomeCatKey = array_key_first(array_filter($categoriesConfig, fn($c) => $c['db_type'] === 'Income'));
if ($incomeCatKey) $categoriesConfig[$incomeCatKey]['budget'] += $amt;
}
} else { } else {
$total_expenses_prevues += $amt; $total_expenses_prevues += $amt;
if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) {
// 1. C'est une charge fixe (Non-variable)
if ($item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) {
$fixedChargesList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount];
$isPaid = false; $isPaid = false;
if (in_array($item['id'], $paidItemIds)) $isPaid = true; if (in_array($item['id'], $paidItemIds)) $isPaid = true;
elseif (!empty($item['mapping_keywords'])) { elseif (!empty($item['mapping_keywords'])) {
@@ -204,83 +280,112 @@ while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
} }
if (!$isPaid) { if (!$isPaid) {
$reste_a_venir_calc += $absAmount; $reste_a_venir_calc += $absAmount;
$pending_charges[] = ['name' => $name, 'amount' => $absAmount]; // Ajouté dans le tableau avec un tooltip vide
$pending_charges[] = ['name' => $name, 'amount' => $absAmount, 'tooltip' => 'Charge fixe en attente'];
}
}
// 2. C'est une estimation (Variable)
if ((int)$item['is_estimate'] === 1) {
$estimatesList[] = [
'name' => $name,
'amount' => $amt,
'categories' => !empty($catCode) ? array_map('trim', explode(',', $catCode)) : []
];
}
// 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];
if (isset($categoriesConfig[$firstCat])) {
$categoriesConfig[$firstCat]['budget'] += $amt;
} }
} }
if ($name === 'Estimacio F&B & beauty') $budget_fmcg = $amt;
elseif ($name === 'Estimacio escola') $budget_school = $amt;
elseif ($name === 'Estimation gasolina') $budget_essence = $amt;
elseif ((int)$item['is_estimate'] === 0 && $item['type'] === 'Mensuel' && $item['category'] === 'expense') { $budget_frais += $absAmount; }
} }
} }
$budget_autres = max(0, $total_income - $total_expenses_prevues); $stmtSalaries = $pdo->query("SELECT id, person, salary FROM pf_salary_config WHERE year = " . $viewY);
while ($sal = $stmtSalaries->fetch(PDO::FETCH_ASSOC)) {
$incomeList[] = [
'id' => 'SAL_' . $sal['id'],
'name' => 'Salaire ' . $sal['person'],
'amount' => (float)$sal['salary']
];
}
// TRADUCTION DES CATÉGORIES // L'enveloppe "Autres" prend tout le reste du budget non alloué
$categoriesConfig = [ $categoriesConfig['AUTRES']['budget'] = max(0, $total_income - $total_expenses_prevues);
'Income' => ['type'=>'credit', 'label'=>tr('cat_income'), 'budget'=>$budget_income_prevu, 'color'=>'#10b981', 'suggestions'=>[]],
'FMCG' => ['type'=>'debit', 'label'=>tr('cat_fmcg'), 'budget'=>$budget_fmcg, 'color'=>'#3b82f6', 'suggestions'=>['Action', 'Carrefour', 'Lidl']],
'Essence' => ['type'=>'debit', 'label'=>tr('cat_fuel'), 'budget'=>$budget_essence, 'color'=>'#f59e0b', 'suggestions'=>['Audi', 'Polo']],
'School' => ['type'=>'debit', 'label'=>tr('cat_school'), 'budget'=>$budget_school, 'color'=>'#10b981', 'suggestions'=>[]],
'Frais' => ['type'=>'debit', 'label'=>tr('cat_fixed'), 'budget'=>$budget_frais, 'color'=>'#ef4444', 'suggestions'=>[]],
'Autres' => ['type'=>'debit', 'label'=>tr('cat_others'), 'budget'=>$budget_autres, 'color'=>'#64748b', 'suggestions'=>['Restaurant', 'Cadeau']],
'LivretA' => ['type'=>'debit', 'label'=>tr('cat_savings'),'budget'=>0, 'color'=>'#8b5cf6', 'suggestions'=>['Virement']]
];
$totals = array_fill_keys(array_keys($categoriesConfig), 0); $totals = array_fill_keys(array_keys($categoriesConfig), 0);
$expensesByCategory = array_fill_keys(array_keys($categoriesConfig), []); $expensesByCategory = array_fill_keys(array_keys($categoriesConfig), []);
$total_rentrees = 0; $total_rentrees = 0;
$depenses_reelles = 0; $depenses_reelles = 0;
// VENTILATION DYNAMIQUE DES DÉPENSES
foreach ($allExpenses as $exp) { foreach ($allExpenses as $exp) {
$cat = $exp['category']; $cat = $exp['category'];
if (!isset($totals[$cat])) $cat = 'Autres'; if (!isset($categoriesConfig[$cat])) $cat = 'AUTRES';
$val = (float)$exp['amount']; $val = (float)$exp['amount'];
if ($cat === 'Income') { $totals[$cat] += $val; } if ($categoriesConfig[$cat]['db_type'] === 'Income') {
else { $totals[$cat] += $val;
if ($val > 0) $categoriesConfig[$cat]['budget'] += $val; } else {
if ($val > 0) $categoriesConfig[$cat]['budget'] += $val; // Remboursement
else $totals[$cat] += abs($val); else $totals[$cat] += abs($val);
} }
$expensesByCategory[$cat][] = $exp; $expensesByCategory[$cat][] = $exp;
if ($val > 0) { if ($val > 0) {
if ($cat === 'Income' || $cat !== 'Frais') $total_rentrees += $val; if ($categoriesConfig[$cat]['db_type'] === 'Income') { $total_rentrees += $val; }
else $depenses_reelles -= $val; else { $depenses_reelles -= $val; }
} else { } else {
$depenses_reelles += abs($val); $depenses_reelles += abs($val);
} }
} }
// Reste à venir dynamiques // CALCUL DES RESTES À VENIR BASÉ SUR LES ESTIMATIONS (MACRO)
$ecole_depense = isset($totals['School']) ? $totals['School'] : 0; foreach ($estimatesList as $est) {
$reste_ecole = max(0, $budget_school - $ecole_depense); $spentForEstimate = 0;
if ($reste_ecole > 0) { $detailsHover = [];
$reste_a_venir_calc += $reste_ecole;
$pending_charges[] = ['name' => tr('bud_rem_school'), 'amount' => $reste_ecole];
}
$fmcg_depense = isset($totals['FMCG']) ? $totals['FMCG'] : 0; // On additionne les dépenses de toutes les catégories liées à cette estimation
$reste_fmcg = max(0, $budget_fmcg - $fmcg_depense); foreach ($est['categories'] as $cCode) {
if ($reste_fmcg > 0) { if (!empty($cCode) && isset($totals[$cCode])) {
$reste_a_venir_calc += $reste_fmcg; $spentForEstimate += $totals[$cCode];
$pending_charges[] = ['name' => tr('bud_rem_fmcg'), 'amount' => $reste_fmcg]; // On prépare le détail pour la petite bulle d'info (hover)
} if ($totals[$cCode] > 0 && isset($categoriesConfig[$cCode])) {
$detailsHover[] = strip_tags($categoriesConfig[$cCode]['label']) . " : " . number_format($totals[$cCode], 0) . "";
}
}
}
$essence_depense = isset($totals['Essence']) ? $totals['Essence'] : 0; $rem = max(0, $est['amount'] - $spentForEstimate);
$reste_essence = max(0, $budget_essence - $essence_depense); if ($rem > 0) {
if ($reste_essence > 0) { $reste_a_venir_calc += $rem;
$reste_a_venir_calc += $reste_essence; $tooltip = !empty($detailsHover) ? implode(' | ', $detailsHover) : 'Aucune dépense pour le moment';
$pending_charges[] = ['name' => tr('bud_rem_fuel'), 'amount' => $reste_essence]; $pending_charges[] = [
'name' => 'Reste ' . $est['name'],
'amount' => $rem,
'tooltip' => $tooltip
];
}
} }
// F. Calculs des KPIs finaux // F. Calculs des KPIs finaux
$rentrees_salaires_reels = $totals['Income'] ?? 0; $rentrees_salaires_reels = 0;
foreach($categoriesConfig as $code => $conf) { if($conf['db_type'] === 'Income') $rentrees_salaires_reels += ($totals[$code] ?? 0); }
$rentrees_autres = $total_rentrees - $rentrees_salaires_reels; $rentrees_autres = $total_rentrees - $rentrees_salaires_reels;
$salaires_retenus = max($rentrees_salaires_reels, $budget_income_prevu); $salaires_retenus = max($rentrees_salaires_reels, $budget_income_prevu);
$capacite_max_calc = $solde_initial + $salaires_retenus + $rentrees_autres; $capacite_max_calc = $solde_initial + $salaires_retenus + $rentrees_autres;
$solde_theorique_calc = ($solde_initial + $total_rentrees) - $depenses_reelles - $reste_a_venir_calc;
$revenus_a_venir = max(0, $budget_income_prevu - $rentrees_salaires_reels);
$solde_theorique_calc = $snapshot['amount'] + $revenus_a_venir - $reste_a_venir_calc;
if ($isClosed) { if ($isClosed) {
$solde_actuel = $monthState['solde_actuel']; $solde_actuel = $monthState['solde_actuel'];
@@ -310,7 +415,6 @@ function getDisplayLogic($spent, $bg, $type) {
return ['pct' => $pct, 'isOver' => $isOver, 'text' => $text]; return ['pct' => $pct, 'isOver' => $isOver, 'text' => $text];
} }
// Noms des mois traduits
$monthNames = [ $monthNames = [
1 => tr('month_01'), 2 => tr('month_02'), 3 => tr('month_03'), 4 => tr('month_04'), 1 => tr('month_01'), 2 => tr('month_02'), 3 => tr('month_03'), 4 => tr('month_04'),
5 => tr('month_05'), 6 => tr('month_06'), 7 => tr('month_07'), 8 => tr('month_08'), 5 => tr('month_05'), 6 => tr('month_06'), 7 => tr('month_07'), 8 => tr('month_08'),
@@ -396,7 +500,7 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div> <div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div>
<?php else: ?> <?php else: ?>
<?php foreach($pending_charges as $pc): ?> <?php foreach($pending_charges as $pc): ?>
<div style="display:flex; justify-content:space-between; margin-bottom:6px;"> <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> <span><?= htmlspecialchars($pc['name']) ?></span>
<strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong> <strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong>
</div> </div>
@@ -480,11 +584,11 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
</div> </div>
</div> </div>
<?php if (!$isClosed): ?> <?php if (!$isClosed): ?>
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes($conf['label']) ?>')"></button> <button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes(strip_tags($conf['label'])) ?>')"></button>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php $barCol = ($key === 'Income') ? '#10b981' : ($logic['isOver'] ? '#ef4444' : $conf['color']); ?> <?php $barCol = ($conf['db_type'] === 'Income') ? '#10b981' : ($logic['isOver'] ? '#ef4444' : $conf['color']); ?>
<div style="background:#f1f5f9; height:4px; width:100%;"> <div style="background:#f1f5f9; height:4px; width:100%;">
<div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div> <div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div>
</div> </div>
@@ -543,7 +647,7 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<label class="pf-label"><?= tr('bud_category') ?></label> <label class="pf-label"><?= tr('bud_category') ?></label>
<select name="category" id="modalCatSelect" class="pf-input" onchange="handleModalCatChange(this)"> <select name="category" id="modalCatSelect" class="pf-input" onchange="handleModalCatChange(this)">
<?php foreach($categoriesConfig as $key => $conf): ?> <?php foreach($categoriesConfig as $key => $conf): ?>
<option value="<?= $key ?>"><?= $conf['label'] ?></option> <option value="<?= $key ?>"><?= strip_tags($conf['label']) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
@@ -562,27 +666,21 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<datalist id="modalSuggestions"></datalist> <datalist id="modalSuggestions"></datalist>
</div> </div>
<div class="form-group" id="blockInputSelect" style="margin-bottom:15px; display:none;">
<label class="pf-label"><?= tr('bud_beneficiary') ?></label>
<select name="label_select" id="schoolSelect" class="pf-input">
<option value="Ecole Pol">Ecole Pol</option>
<option value="Carole">Carole</option>
</select>
</div>
<div class="form-group" id="blockInputFrais" style="margin-bottom:15px; display:none;"> <div class="form-group" id="blockInputFrais" style="margin-bottom:15px; display:none;">
<label class="pf-label"><?= tr('bud_fixed_charge') ?></label> <label class="pf-label">Lier à une Charge Fixe (Optionnel)</label>
<select name="budget_item_id" id="fraisSelect" class="pf-input" disabled> <select name="budget_item_id" id="fraisSelect" class="pf-input" disabled>
<option value=""><?= tr('bud_select_beneficiary') ?></option> <option value="">-- Aucune --</option>
<?php foreach ($fixedChargesList as $fc): ?><option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['name']) ?></option><?php endforeach; ?> <?php foreach ($fixedChargesList as $fc): ?><option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['name']) ?></option><?php endforeach; ?>
</select> </select>
</div> </div>
<div class="form-group" id="blockInputIncome" style="margin-bottom:15px; display:none;"> <div class="form-group" id="blockInputIncome" style="margin-bottom:15px; display:none;">
<label class="pf-label"><?= tr('bud_expected_income') ?></label> <label class="pf-label">Lier à un Revenu Prévu (Optionnel)</label>
<select name="budget_item_id" id="incomeSelect" class="pf-input" disabled> <select name="budget_item_id" id="incomeSelect" class="pf-input" disabled>
<option value=""><?= tr('bud_select_beneficiary') ?> </option> <option value="">-- Aucun --</option>
<?php foreach ($incomeList as $inc): ?><option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?></option><?php endforeach; ?> <?php foreach ($incomeList as $inc): ?>
<option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?></option>
<?php endforeach; ?>
</select> </select>
</div> </div>
@@ -690,16 +788,20 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<select name="lines[<?= $idx ?>][cat]" class="pf-input line-select" onchange="handleLineCatChange(this)" <?= $dis ?> style="padding:4px; font-size:0.85rem; flex:1;"> <select name="lines[<?= $idx ?>][cat]" class="pf-input line-select" onchange="handleLineCatChange(this)" <?= $dis ?> style="padding:4px; font-size:0.85rem; flex:1;">
<option value="">-- <?= $isCrd ? tr('bud_ignore') : tr('bud_to_define') ?> --</option> <option value="">-- <?= $isCrd ? tr('bud_ignore') : tr('bud_to_define') ?> --</option>
<?php foreach ($categoriesConfig as $k => $c): ?> <?php foreach ($categoriesConfig as $k => $c): ?>
<option value="<?= $k ?>" <?= ($row['cat']===$k)?'selected':'' ?>><?= $c['label'] ?></option> <option value="<?= $k ?>" <?= ($row['cat']===$k)?'selected':'' ?>><?= strip_tags($c['label']) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-frais" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled> <select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-frais" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled>
<option value="">-- <?= tr('bud_is_charge') ?> --</option> <option value="">-- Lier à une Charge Fixe --</option>
<?php foreach ($fixedChargesList as $fc): ?><option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['name']) ?></option><?php endforeach; ?> <?php foreach ($fixedChargesList as $fc): ?>
<option value="<?= $fc['id'] ?>" <?= ($row['suggested_item_id'] == $fc['id']) ? 'selected' : '' ?>><?= htmlspecialchars($fc['name']) ?></option>
<?php endforeach; ?>
</select> </select>
<select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-income" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled> <select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-income" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled>
<option value="">-- <?= tr('bud_is_income') ?> --</option> <option value="">-- <?= tr('bud_is_income') ?> --</option>
<?php foreach ($incomeList as $inc): ?><option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?></option><?php endforeach; ?> <?php foreach ($incomeList as $inc): ?>
<option value="<?= $inc['id'] ?>" <?= ($row['suggested_item_id'] == $inc['id']) ? 'selected' : '' ?>><?= htmlspecialchars($inc['name']) ?></option>
<?php endforeach; ?>
</select> </select>
</div> </div>
</td> </td>
@@ -723,7 +825,6 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
document.body.classList.add('no-scroll'); document.body.classList.add('no-scroll');
<?php endif; ?> <?php endif; ?>
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR"; window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = { window.I18N = {
...(window.I18N || {}), ...(window.I18N || {}),
@@ -735,34 +836,46 @@ window.I18N = {
'btn_delete': <?= json_encode(tr('btn_delete')) ?>, 'btn_delete': <?= json_encode(tr('btn_delete')) ?>,
}; };
// --- DICTIONNAIRE JS DYNAMIQUE ---
const catConfigs = <?= json_encode($categoriesConfig) ?>;
const activeViewMonth = '<?= substr($viewMonthDate, 0, 7) ?>'; const activeViewMonth = '<?= substr($viewMonthDate, 0, 7) ?>';
function toggleDiv(id) { const el = document.getElementById(id); el.style.display = (el.style.display === 'none') ? 'block' : 'none'; } function toggleDiv(id) { const el = document.getElementById(id); el.style.display = (el.style.display === 'none') ? 'block' : 'none'; }
function openSuiviModal(id) { document.getElementById(id).classList.add('open'); document.body.classList.add('no-scroll'); } function openSuiviModal(id) { document.getElementById(id).classList.add('open'); document.body.classList.add('no-scroll'); }
function closeSuiviModal(id) { document.getElementById(id).classList.remove('open'); document.body.classList.remove('no-scroll');} function closeSuiviModal(id) { document.getElementById(id).classList.remove('open'); document.body.classList.remove('no-scroll');}
const suggestions = <?= json_encode(array_map(fn($c) => $c['suggestions'], $categoriesConfig)) ?>; // Gestion dynamique du formulaire modal
function handleModalCatChange(select) { function handleModalCatChange(select) {
const catKey = select.value; const catKey = select.value.toUpperCase(); // Sécurité sur la casse
const conf = catConfigs[select.value] || {db_type: 'Expense', suggestions: []};
document.getElementById('blockInputText').style.display = 'none'; document.getElementById('blockInputText').style.display = 'block';
document.getElementById('blockInputSelect').style.display = 'none';
document.getElementById('blockInputFrais').style.display = 'none'; document.getElementById('blockInputFrais').style.display = 'none';
document.getElementById('blockInputIncome').style.display = 'none'; document.getElementById('blockInputIncome').style.display = 'none';
document.getElementById('modalLabelInput').required = false; document.getElementById('modalLabelInput').required = true;
document.getElementById('fraisSelect').required = false; document.getElementById('fraisSelect').required = false;
document.getElementById('incomeSelect').required = false; document.getElementById('incomeSelect').required = false;
document.getElementById('fraisSelect').disabled = true; document.getElementById('fraisSelect').disabled = true;
document.getElementById('incomeSelect').disabled = true; document.getElementById('incomeSelect').disabled = true;
if (catKey === 'School') { document.getElementById('blockInputSelect').style.display = 'block'; } if (conf.db_type === 'Income') {
else if (catKey === 'Frais') { document.getElementById('blockInputText').style.display = 'block'; document.getElementById('blockInputFrais').style.display = 'block'; document.getElementById('fraisSelect').required = true; document.getElementById('fraisSelect').disabled = false; } const incSel = document.getElementById('incomeSelect');
else if (catKey === 'Income') { document.getElementById('blockInputText').style.display = 'block'; document.getElementById('blockInputIncome').style.display = 'block'; document.getElementById('incomeSelect').required = true; document.getElementById('incomeSelect').disabled = false; } // 👁️ Ne s'affiche que s'il y a des revenus prévus à lier, sinon reste invisible
else { if (incSel.options.length > 1) {
document.getElementById('blockInputText').style.display = 'block'; document.getElementById('modalLabelInput').required = true; document.getElementById('blockInputIncome').style.display = 'block';
incSel.disabled = false;
}
}
else if (catKey === 'FIXED') { // 🔒 Strictement pour les Charges Fixes
document.getElementById('blockInputFrais').style.display = 'block';
document.getElementById('fraisSelect').disabled = false;
document.getElementById('fraisSelect').required = true; // 🔥 Devient OBLIGATOIRE
}
const list = document.getElementById('modalSuggestions'); list.innerHTML = ''; const list = document.getElementById('modalSuggestions'); list.innerHTML = '';
if (suggestions[catKey]) { suggestions[catKey].forEach(i => { const op = document.createElement('option'); op.value = i; list.appendChild(op); }); } if (conf.suggestions) {
conf.suggestions.forEach(i => { const op = document.createElement('option'); op.value = i; list.appendChild(op); });
} }
} }
@@ -774,8 +887,10 @@ function openAddModal(catKey, catLabel) {
document.getElementById('modalGestionMonth').value = activeViewMonth; document.getElementById('modalGestionMonth').value = activeViewMonth;
document.getElementById('modalLabelInput').value = ""; document.getElementById('modalLabelInput').value = "";
document.getElementById('modalAmount').value = ""; document.getElementById('modalAmount').value = "";
const catSelect = document.getElementById('modalCatSelect'); catSelect.value = catKey; handleModalCatChange(catSelect);
document.getElementById('modalIsCredit').value = (catKey === 'Income') ? "1" : "0"; const catSelect = document.getElementById('modalCatSelect'); catSelect.value = catKey;
handleModalCatChange(catSelect);
document.getElementById('modalIsCredit').value = (catConfigs[catKey]?.db_type === 'Income') ? "1" : "0";
} }
function openEditModal(e) { function openEditModal(e) {
@@ -787,17 +902,35 @@ function openEditModal(e) {
document.getElementById('modalLabelInput').value = e.label; document.getElementById('modalLabelInput').value = e.label;
document.getElementById('modalIsCredit').value = parseFloat(e.amount) > 0 ? "1" : "0"; document.getElementById('modalIsCredit').value = parseFloat(e.amount) > 0 ? "1" : "0";
document.getElementById('modalAmount').value = Math.abs(parseFloat(e.amount)); document.getElementById('modalAmount').value = Math.abs(parseFloat(e.amount));
const catSelect = document.getElementById('modalCatSelect'); catSelect.value = e.category; handleModalCatChange(catSelect);
if (e.category === 'Frais') document.getElementById('fraisSelect').value = e.budget_item_id; const catSelect = document.getElementById('modalCatSelect'); catSelect.value = e.category;
else if (e.category === 'Income') document.getElementById('incomeSelect').value = e.budget_item_id; handleModalCatChange(catSelect);
if (catConfigs[e.category]?.db_type === 'Income') document.getElementById('incomeSelect').value = e.budget_item_id;
else document.getElementById('fraisSelect').value = e.budget_item_id;
} }
function handleLineCatChange(select) { // Gestion dynamique du CSV Import
function handleLineCatChange(select, isInit = false) {
const row = select.closest('tr'); const row = select.closest('tr');
const fSel = row.querySelector('.select-frais'); const iSel = row.querySelector('.select-income'); const fSel = row.querySelector('.select-frais');
fSel.style.display = 'none'; iSel.style.display = 'none'; fSel.value = ''; iSel.value = ''; fSel.disabled = true; iSel.disabled = true; const iSel = row.querySelector('.select-income');
if (select.value === 'Frais') { fSel.style.display = 'block'; fSel.disabled = false; } const catKey = select.value.toUpperCase();
else if (select.value === 'Income') { iSel.style.display = 'block'; iSel.disabled = false; } const conf = catConfigs[select.value] || null;
fSel.style.display = 'none'; iSel.style.display = 'none';
if (!isInit) { fSel.value = ''; iSel.value = ''; }
fSel.disabled = true; iSel.disabled = true;
if (conf && conf.db_type === 'Income') {
// 👁️ Ne s'affiche que s'il y a des revenus à lier
if (iSel.options.length > 1) {
iSel.style.display = 'block'; iSel.disabled = false;
}
}
else if (catKey === 'FIXED') { // 🔒 Strictement pour les Charges Fixes
fSel.style.display = 'block'; fSel.disabled = false;
}
checkValidation(); checkValidation();
} }
@@ -806,27 +939,45 @@ function toggleAll(src) { document.querySelectorAll('.line-checkbox:not([disable
function checkValidation() { function checkValidation() {
let miss = 0; let miss = 0;
document.querySelectorAll('.line-checkbox:checked').forEach(cb => { document.querySelectorAll('.line-checkbox:checked').forEach(cb => {
const row = cb.closest('tr'); const isCrd = row.querySelector('.is-credit-flag').value === '1'; const cat = row.querySelector('.line-select').value; const row = cb.closest('tr');
let v = true; if (cat==="") { if(!isCrd) v = false; } else if (cat==='Frais' && row.querySelector('.select-frais').value==="") v=false; else if (cat==='Income' && row.querySelector('.select-income').value==="") v=false; const isCrd = row.querySelector('.is-credit-flag').value === '1';
const cat = row.querySelector('.line-select').value;
const catKey = cat.toUpperCase();
const fSel = row.querySelector('.select-frais');
let v = true;
if (cat === "") {
// La catégorie est obligatoire pour les dépenses (les revenus sont acceptés sans cat si souhaité)
if (!isCrd) v = false;
}
else if (catKey === 'FIXED' && fSel.value === "") {
v = false; // 🔥 Bloque l'import si une Charge Fixe n'est pas liée à une ligne du budget !
}
if (!v) { miss++; row.style.background = '#fff1f2'; } else row.style.background = ''; if (!v) { miss++; row.style.background = '#fff1f2'; } else row.style.background = '';
}); });
const btn = document.getElementById('btnImport'); const msg = document.getElementById('missingCount'); const btn = document.getElementById('btnImport'); const msg = document.getElementById('missingCount');
if(miss>0) { btn.disabled = true; btn.style.opacity=0.5; msg.style.display='inline'; msg.innerText = miss + ' ' + (window.I18N['bud_to_define_js'] || ''); } if(miss > 0) { btn.disabled = true; btn.style.opacity = 0.5; msg.style.display = 'inline'; msg.innerText = miss + ' ' + (window.I18N['bud_to_define_js'] || 'à définir'); }
else { btn.disabled = false; btn.style.opacity=1; msg.style.display='none'; } else { btn.disabled = false; btn.style.opacity = 1; msg.style.display = 'none'; }
} }
if(document.getElementById('formMapping')) { document.querySelectorAll('.line-select').forEach(s => handleLineCatChange(s)); checkValidation(); } if (document.getElementById('formMapping')) {
document.querySelectorAll('.line-select').forEach(s => handleLineCatChange(s, true));
checkValidation();
}
window.addEventListener('click', (e) => { window.addEventListener('click', (e) => {
if (e.target.classList.contains('pf-modal')) { if (e.target.classList.contains('pf-modal')) {
e.target.style.display = 'none'; e.target.classList.remove('open', 'is-active');
e.target.style.display = '';
document.body.classList.remove('no-scroll'); document.body.classList.remove('no-scroll');
} }
}); });
// --- 2. SUPPRESSION ASYNCHRONE --- // --- 2. SUPPRESSION ASYNCHRONE ---
async function deleteExpense(id) { async function deleteExpense(id) {
const confirmed = await pachaConfirm("Suppression", tr('bud_confirm_delete')); const confirmed = await pachaConfirm("Suppression", window.I18N['bud_confirm_delete']);
if (!confirmed) return; if (!confirmed) return;
const formData = new FormData(); const formData = new FormData();
@@ -856,7 +1007,6 @@ document.addEventListener('DOMContentLoaded', () => {
submitBtn.innerText = '⏳ ...'; submitBtn.innerText = '⏳ ...';
const formData = new FormData(formExpense); const formData = new FormData(formExpense);
// Force l'action ici pour être sûr
formData.set('action', 'save_expense_manual'); formData.set('action', 'save_expense_manual');
const actionUrl = formExpense.getAttribute('action') || 'modules/budget/includes/api/manage-item.php'; const actionUrl = formExpense.getAttribute('action') || 'modules/budget/includes/api/manage-item.php';
+288 -206
View File
@@ -32,7 +32,9 @@
padding-bottom: 40px; padding-bottom: 40px;
} }
/* En-tête & Boutons */ /* =========================================================
EN-TÊTE & BOUTONS
========================================================= */
.fc-header-row { .fc-header-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -62,7 +64,9 @@
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
} }
/* Calendrier Mensuel */ /* =========================================================
CALENDRIER MENSUEL (Vue Grille Mois)
========================================================= */
.fc-month-calendar-wrapper { .fc-month-calendar-wrapper {
background: white; background: white;
border: 1px solid var(--pf-border); border: 1px solid var(--pf-border);
@@ -114,6 +118,7 @@
.fc-view-controls { .fc-view-controls {
display: flex !important; display: flex !important;
align-items: center;
width: 100% !important; width: 100% !important;
background: #f1f5f9 !important; background: #f1f5f9 !important;
padding: 4px !important; padding: 4px !important;
@@ -128,7 +133,7 @@
padding: 6px 16px; padding: 6px 16px;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
color: var(--pf-text-muted); color: #64748b;
cursor: pointer; cursor: pointer;
transition: 0.2s; transition: 0.2s;
} }
@@ -206,7 +211,9 @@
text-transform: capitalize; text-transform: capitalize;
} }
/* Décompte Congés (Pills) */ /* =========================================================
DÉCOMPTE CONGÉS MENSUEL (PILLS)
========================================================= */
.fc-month-balances { .fc-month-balances {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -226,12 +233,6 @@
border: 1px solid var(--pf-border); border: 1px solid var(--pf-border);
box-shadow: var(--pf-shadow-sm); box-shadow: var(--pf-shadow-sm);
} }
.fc-minimal-balance-card strong.alex {
color: var(--text-alex);
}
.fc-minimal-balance-card strong.laia {
color: var(--text-laia);
}
.fc-minimal-chips { .fc-minimal-chips {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -249,17 +250,17 @@
} }
.fc-min-chip .type { .fc-min-chip .type {
font-weight: 700; font-weight: 700;
color: var(--pf-text-muted); color: #64748b;
margin-right: 6px; margin-right: 6px;
} }
.fc-min-chip .val { .fc-min-chip .val {
font-weight: 800; font-weight: 800;
color: var(--pf-text-main); color: #0f172a;
} }
.fc-used-badge { .fc-used-badge {
margin-left: 6px; margin-left: 6px;
background: #fee2e2; background: #fee2e2;
color: var(--pf-danger); color: #ef4444;
padding: 1px 5px; padding: 1px 5px;
border-radius: 10px; border-radius: 10px;
font-size: 0.65rem; font-size: 0.65rem;
@@ -272,7 +273,7 @@
font-size: 0.9rem; font-size: 0.9rem;
display: flex; display: flex;
align-items: center; align-items: center;
color: var(--pf-danger); color: #ef4444;
font-weight: bold; font-weight: bold;
} }
@keyframes pulseBurn { @keyframes pulseBurn {
@@ -290,7 +291,9 @@
} }
} }
/* Couleurs des jours */ /* =========================================================
COULEURS DES JOURS (GRILLES)
========================================================= */
.fc-day--school-holiday { .fc-day--school-holiday {
background: var(--c-school-holiday) !important; background: var(--c-school-holiday) !important;
} }
@@ -304,35 +307,13 @@
.fc-day--extra-off-carole { .fc-day--extra-off-carole {
background: var(--c-extra-off) !important; background: var(--c-extra-off) !important;
} }
.fc-day--selected { .fc-day--selected {
background: var(--c-selected) !important; background: var(--c-selected) !important;
outline: 2px solid var(--pf-primary); outline: 2px solid var(--pf-primary);
z-index: 5; z-index: 5;
} }
.fc-day--centre::after {
content: "🏫";
position: absolute;
top: 2px;
right: 2px;
font-size: 12px;
}
.fc-day--avis::after {
content: "";
position: absolute;
top: 2px;
right: 2px;
width: 14px;
height: 14px;
background: url("/modules/family-calendar/assets/img/avis.svg") no-repeat
center;
background-size: contain;
}
.fc-pep-sick-emoji {
position: absolute;
bottom: 2px;
right: 2px;
font-size: 12px;
}
.fc-day--off-carole.fc-day--school-holiday, .fc-day--off-carole.fc-day--school-holiday,
.fc-day--extra-off-carole.fc-day--school-holiday, .fc-day--extra-off-carole.fc-day--school-holiday,
.fc-day--centre.fc-day--school-holiday, .fc-day--centre.fc-day--school-holiday,
@@ -340,7 +321,26 @@
box-shadow: inset 0 -8px 0 0 var(--c-school-holiday) !important; box-shadow: inset 0 -8px 0 0 var(--c-school-holiday) !important;
} }
/* Planning Hebo (Desktop) */ .fc-day--today {
background: #eff6ff !important;
box-shadow: inset 0 0 0 1px #bfdbfe !important;
}
.fc-day--today .fc-day-number {
display: inline;
background: none;
width: auto;
height: auto;
box-shadow: none;
border-radius: 0;
margin: 0;
color: var(--pf-primary);
font-weight: 900;
font-size: 1.1em;
}
/* =========================================================
PLANNING GLOBAL (Matrice Annuelle)
========================================================= */
.fc-week-header { .fc-week-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -379,22 +379,17 @@
border-spacing: 0; border-spacing: 0;
} }
/* ========================================================= /* HARMONISATION DES BORDURES ET HEADERS STICKY */
HARMONISATION DES BORDURES ET HEADERS STICKY (1px strict)
========================================================= */
#planningTable thead tr { #planningTable thead tr {
height: 30px; /* On fixe la hauteur pour un calcul parfait */ height: 30px;
} }
#planningTable thead th { #planningTable thead th {
position: sticky; position: sticky;
background: #f8fafc !important; background: #f8fafc !important;
border: none !important; /* On supprime les bordures natives */ border: none !important;
/* On utilise UNIQUEMENT des ombres internes pour simuler 1px de bordure sans doublement */
box-shadow: box-shadow:
inset 0 -1px 0 var(--pf-border), inset 0 -1px 0 var(--pf-border),
inset -1px 0 0 var(--pf-border) !important; inset -1px 0 0 var(--pf-border) !important;
padding: 6px; padding: 6px;
font-size: 0.75rem; font-size: 0.75rem;
z-index: 20; z-index: 20;
@@ -403,14 +398,14 @@
} }
#planningTable thead tr:nth-child(1) th { #planningTable thead tr:nth-child(1) th {
top: 0; top: 0;
z-index: 22; /* Au premier plan */ z-index: 22;
} }
#planningTable thead tr:nth-child(2) th { #planningTable thead tr:nth-child(2) th {
top: 30px; /* Exactement la hauteur de la ligne 1 */ top: 30px;
z-index: 21; z-index: 21;
} }
#planningTable thead tr:nth-child(3) th { #planningTable thead tr:nth-child(3) th {
top: 60px; /* Ligne 1 + Ligne 2 */ top: 60px;
z-index: 20; z-index: 20;
} }
@@ -425,10 +420,9 @@
color: var(--text-laia) !important; color: var(--text-laia) !important;
} }
/* Cellules standards du corps */
#planningTable tbody td { #planningTable tbody td {
position: relative; position: relative;
border: none !important; /* Reset de sécurité */ border: none !important;
border-right: 1px solid var(--pf-border) !important; border-right: 1px solid var(--pf-border) !important;
border-bottom: 1px solid var(--pf-border) !important; border-bottom: 1px solid var(--pf-border) !important;
height: 36px; height: 36px;
@@ -467,13 +461,36 @@ td.col-laia-sub {
writing-mode: vertical-rl; writing-mode: vertical-rl;
transform: rotate(180deg); transform: rotate(180deg);
font-size: 0.7rem; font-size: 0.7rem;
color: var(--pf-text-muted); color: #64748b;
} }
/* ========================================================= /* ==========================================================================
GESTION DES COLONNES STICKY (Mois & Semaine) OPTIMISATION DES LARGEURS DU TABLEAU HEBDOMADAIRE
========================================================= */ ========================================================================== */
/* 1. On limite les colonnes des jours (Lun-Ven) pour qu'elles prennent ~55% de la page max */
#planningTable th.col-day,
#planningTable td.col-day {
width: 11%;
max-width: 90px;
}
/* 2. On compacte la colonne "Sem." qui n'affiche plus que 2 chiffres */
#planningTable th.col-sticky-sem,
#planningTable td.col-sticky-sem {
width: 40px;
min-width: 40px;
text-align: center;
padding: 4px; /* Réduit les marges internes */
}
/* 3. On s'assure que le tableau utilise tout l'espace disponible intelligemment */
#planningTable {
table-layout: auto;
width: 100%;
}
/* GESTION DES COLONNES STICKY (Mois & Semaine) */
#planningTable tbody td.col-sticky-mois { #planningTable tbody td.col-sticky-mois {
position: sticky !important; position: sticky !important;
left: 0 !important; left: 0 !important;
@@ -481,7 +498,6 @@ td.col-laia-sub {
background: white !important; background: white !important;
padding: 0 !important; padding: 0 !important;
} }
#planningTable tbody td.col-sticky-mois .fc-sticky-mois-label { #planningTable tbody td.col-sticky-mois .fc-sticky-mois-label {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -491,42 +507,31 @@ td.col-laia-sub {
letter-spacing: 1px; letter-spacing: 1px;
text-align: center; text-align: center;
} }
/* 2. Sticky Semaine (Body) */
#planningTable tbody td.col-sticky-sem { #planningTable tbody td.col-sticky-sem {
position: sticky !important; position: sticky !important;
left: 54px !important; left: 54px !important;
z-index: 14 !important; z-index: 14 !important;
background: white !important; background: white !important;
/* Ombre portée pour délimiter la zone sticky du reste du tableau */
box-shadow: 4px 0 6px -2px rgba(0, 0, 0, 0.1); box-shadow: 4px 0 6px -2px rgba(0, 0, 0, 0.1);
} }
/* 3. Sticky Mois (Header) */
#planningTable thead tr th.col-sticky-mois { #planningTable thead tr th.col-sticky-mois {
position: sticky !important; position: sticky !important;
top: 0 !important; top: 0 !important;
left: 0 !important; left: 0 !important;
z-index: 30 !important; z-index: 30 !important;
background: var(--pf-bg-lighter) !important; background: var(--pf-bg-lighter) !important;
/* Les bordures sont gérées par le box-shadow général du thead th */
} }
/* 4. Sticky Semaine (Header) */
#planningTable thead tr th.col-sticky-sem { #planningTable thead tr th.col-sticky-sem {
position: sticky !important; position: sticky !important;
top: 0 !important; top: 0 !important;
left: 54px !important; left: 54px !important;
z-index: 29 !important; z-index: 29 !important;
background: var(--pf-bg-lighter) !important; background: var(--pf-bg-lighter) !important;
/* Ombre interne (bordures) + Ombre portée (effet sticky) */
box-shadow: box-shadow:
inset 0 -1px 0 var(--pf-border), inset 0 -1px 0 var(--pf-border),
inset -1px 0 0 var(--pf-border), inset -1px 0 0 var(--pf-border),
4px 0 6px -2px rgba(0, 0, 0, 0.1) !important; 4px 0 6px -2px rgba(0, 0, 0, 0.1) !important;
} }
/* Rétablissement horizontal pour les headers mois et semaine */
#planningTable thead th.col-month, #planningTable thead th.col-month,
#planningTable tbody td.col-sticky-sem { #planningTable tbody td.col-sticky-sem {
writing-mode: horizontal-tb; writing-mode: horizontal-tb;
@@ -535,15 +540,15 @@ td.col-laia-sub {
text-align: center; text-align: center;
} }
/* Panneaux bas & Légendes */ /* =========================================================
PANNEAUX BAS, LÉGENDES & RÉSUMÉS
========================================================= */
.fc-bottom-grid { .fc-bottom-grid {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 2fr 1fr;
gap: 24px; gap: 24px;
align-items: start; align-items: start;
} }
/* Nouvelles bordures style "Budget" pour les conteneurs */
.pf-card { .pf-card {
background: white; background: white;
border: 1px solid var(--pf-border); border: 1px solid var(--pf-border);
@@ -560,6 +565,7 @@ td.col-laia-sub {
padding-bottom: 12px; padding-bottom: 12px;
} }
/* Légendes statiques */
.pf-legend-grid { .pf-legend-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -607,53 +613,62 @@ td.col-laia-sub {
border: none; border: none;
} }
.fc-summary-header { /* Résumé Mensuel Injecté */
.fc-month-container {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: 8px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--pf-border);
} }
.fc-summ-select { .fc-month-summary-inline {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 5px;
background: var(--pf-bg-lighter, #f8fafc);
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--pf-border); border: 1px solid var(--pf-border);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.8rem;
height: auto;
background: #f8fafc;
} }
.fc-summary-item { .fc-summ-pill {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
padding: 12px 4px; align-items: center;
border-bottom: 1px solid var(--pf-border); /* Ligne de séparation visible */ font-size: 0.75rem;
}
.fc-summary-item:last-child {
border-bottom: none;
}
.fc-summary-label {
font-size: 0.85rem;
color: #64748b; color: #64748b;
font-weight: 600; flex: 1;
} }
.fc-summary-value { .fc-summ-pill strong {
font-size: 1rem; font-size: 0.95rem;
font-weight: 700;
color: #0f172a; color: #0f172a;
margin-top: 2px;
}
.pf-presence-pill strong {
color: var(--pf-primary);
} }
/* Menu Contextuel Tactile */ [data-theme="dark"] .fc-month-summary-inline {
background: #1c2128;
border-color: #30363d;
}
[data-theme="dark"] .fc-summ-pill strong {
color: #c9d1d9;
}
/* =========================================================
MENU CONTEXTUEL (COMPACT & PROPRE)
========================================================= */
#selectionMenu,
#fc-month-selectionMenu,
.fc-selection-menu { .fc-selection-menu {
position: absolute; position: absolute;
z-index: 9000; z-index: 9000;
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
border: 1px solid rgba(226, 232, 240, 0.8); border: 1px solid rgba(226, 232, 240, 0.8);
box-shadow: var(--pf-shadow-lg); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
border-radius: 16px; border-radius: 12px;
padding: 10px 12px; padding: 8px 12px;
min-width: 220px; min-width: 210px;
display: none; display: none;
animation: menuPopIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; animation: menuPopIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform-origin: top center; transform-origin: top center;
@@ -668,88 +683,70 @@ td.col-laia-sub {
transform: scale(1) translateY(0); transform: scale(1) translateY(0);
} }
} }
.fc-menu-section { .fc-menu-section {
padding: 6px 0; padding: 6px 0;
border-bottom: 1px dashed #cbd5e1; border-bottom: 1px dashed #cbd5e1;
} }
.fc-menu-section:last-child { .fc-menu-section:last-child {
border: none; border-bottom: none;
padding-bottom: 0; padding-bottom: 0;
} }
.fc-menu-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.fc-menu-section strong {
font-size: 0.7rem;
text-transform: uppercase;
color: var(--pf-text-muted);
}
.fc-menu-grid { .fc-menu-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 4px; gap: 6px;
} }
.fc-menu-btn { .fc-menu-btn {
background: white; background: transparent;
border: 1px solid var(--pf-border); border: 1px solid transparent;
padding: 6px 8px; padding: 4px 8px;
border-radius: 8px; border-radius: 6px;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
transition: 0.2s; text-align: left;
box-shadow: var(--pf-shadow-sm); transition: all 0.2s ease;
color: #0f172a;
display: flex;
align-items: center;
} }
.fc-menu-btn:hover { .fc-menu-btn:hover {
background: var(--pf-bg-page); background: #f1f5f9;
border-color: var(--pf-primary);
color: var(--pf-primary);
} }
.fc-menu-clear-icon {
background: transparent; /* --- LE BOUTON ACTIF (SANS COCHE !) --- */
border: none; .fc-menu-btn--active {
color: #94a3b8; border: 1px solid var(--pf-primary) !important;
cursor: pointer; background: #eff6ff !important;
padding: 4px; color: var(--pf-primary) !important;
display: flex; font-weight: 700 !important;
border-radius: 6px;
} }
.fc-menu-clear-icon svg { /* Sécurité absolue pour tuer les coches fantômes */
.fc-menu-btn--active::before,
.fc-menu-btn--active::after {
display: none !important;
content: none !important;
}
/* Icônes intégrées dans le JS */
.fc-icon-centre {
font-size: 1.1rem;
line-height: 1;
margin-right: 4px;
}
.fc-icon-avis {
width: 14px; width: 14px;
height: 14px; height: 14px;
} object-fit: contain;
.fc-menu-clear-icon:hover { margin-right: 4px;
background: #fef2f2; vertical-align: middle;
color: var(--pf-danger);
}
.fc-menu-leaves-table table {
width: 100%;
border-spacing: 2px;
}
.fc-menu-leaves-table th {
font-size: 0.7rem;
color: var(--pf-text-muted);
text-align: center;
}
.fc-th-inline {
display: flex;
justify-content: center;
gap: 4px;
} }
/* --- BOUTON DE MENU ACTIF (Élément déjà enregistré) --- */ /* =========================================================
.fc-menu-btn--active { MODALES (Paramètres & Vacances)
background: #eff6ff; ========================================================= */
border-color: var(--pf-primary);
color: var(--pf-primary);
/* Un léger inset shadow pour rendre la bordure un peu plus épaisse sans casser la taille */
box-shadow: inset 0 0 0 1px var(--pf-primary);
}
/* Table specifique modale vacances */
.fc-holidays-table { .fc-holidays-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -772,7 +769,6 @@ td.col-laia-sub {
background: #fafafa; background: #fafafa;
} }
/* === MODALES : Alignement du Header et de la Croix === */
.pf-modal-header { .pf-modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -781,14 +777,12 @@ td.col-laia-sub {
border-bottom: 1px solid var(--pf-border); border-bottom: 1px solid var(--pf-border);
padding-bottom: 15px; padding-bottom: 15px;
} }
.pf-modal-header .pf-modal-title { .pf-modal-header .pf-modal-title {
margin: 0; margin: 0;
padding: 0; padding: 0;
border-bottom: none; /* On annule la bordure par défaut du global.css pour la déléguer au header */ border-bottom: none;
flex-grow: 1; flex-grow: 1;
} }
.pf-modal-close { .pf-modal-close {
background: none; background: none;
border: none; border: none;
@@ -799,9 +793,37 @@ td.col-laia-sub {
padding: 0 0 0 15px; padding: 0 0 0 15px;
transition: color 0.2s; transition: color 0.2s;
} }
.pf-modal-close:hover { .pf-modal-close:hover {
color: #ef4444; /* Devient rouge au survol */ color: #ef4444;
}
.fc-sidebar-tab {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 1.2rem;
background: transparent;
border: none;
text-align: left;
font-weight: 600;
cursor: pointer;
color: #0f172a;
transition: all 0.2s;
width: calc(100% - 16px);
margin: 2px 8px;
border-radius: 8px;
font-size: 0.95rem;
}
.fc-sidebar-tab.active {
background: #111827;
color: #ffffff;
}
.fc-sidebar-tab:hover:not(.active) {
background: #f1f5f9;
}
[data-theme="dark"] .fc-sidebar-tab.active {
background: var(--pf-primary);
color: #ffffff;
} }
/* ========================================================= /* =========================================================
@@ -811,36 +833,25 @@ td.col-laia-sub {
background: #ffffff; background: #ffffff;
border: 1px solid #cbd5e1; border: 1px solid #cbd5e1;
border-radius: 8px; border-radius: 8px;
font-family: inherit; font-family: inherit;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--text-main); color: #0f172a;
/* Padding standard */
padding: 8px 12px; padding: 8px 12px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
text-transform: capitalize; text-transform: capitalize;
box-shadow: var(--pf-shadow-sm); box-shadow: var(--pf-shadow-sm);
} }
.fc-smart-select:hover { .fc-smart-select:hover {
border-color: #94a3b8; border-color: #94a3b8;
} }
.fc-smart-select:focus { .fc-smart-select:focus {
background: #ffffff; border-color: var(--pf-primary);
border-color: var(--primary);
outline: none; outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
} }
/* --- BOUTON AUJOURD'HUI & CONTROLES --- */
.fc-view-controls {
align-items: center; /* Pour bien centrer le nouveau bouton */
}
.fc-today-button { .fc-today-button {
background: white; background: white;
border: 1px solid var(--pf-border); border: 1px solid var(--pf-border);
@@ -853,12 +864,10 @@ td.col-laia-sub {
transition: all 0.2s; transition: all 0.2s;
box-shadow: var(--pf-shadow-sm); box-shadow: var(--pf-shadow-sm);
} }
.fc-today-button:hover { .fc-today-button:hover {
background: #eff6ff; background: #eff6ff;
border-color: var(--pf-primary); border-color: var(--pf-primary);
} }
.fc-view-divider { .fc-view-divider {
width: 1px; width: 1px;
height: 20px; height: 20px;
@@ -866,34 +875,14 @@ td.col-laia-sub {
margin: 0 8px; margin: 0 8px;
} }
/* --- MISE EN ÉVIDENCE DU JOUR EN COURS --- */ /* =========================================================
.fc-day--today { MOBILE RESPONSIVE
background: #eff6ff !important; ========================================================= */
box-shadow: inset 0 0 0 1px #bfdbfe !important;
}
.fc-day--today .fc-day-number {
display: inline;
background: none;
width: auto;
height: auto;
box-shadow: none;
border-radius: 0;
margin: 0;
color: var(--pf-primary);
font-weight: 900;
font-size: 1.1em;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.fc-smart-select { .fc-smart-select {
font-size: 1rem; font-size: 1rem;
padding: 6px 8px; padding: 6px 8px;
} }
}
/* Mobile */
@media (max-width: 768px) {
.pf-family-calendar .pf-container { .pf-family-calendar .pf-container {
padding: 0 !important; padding: 0 !important;
} }
@@ -927,6 +916,9 @@ td.col-laia-sub {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 10px; padding: 10px;
} }
#selectionMenu,
#fc-month-selectionMenu,
.fc-selection-menu { .fc-selection-menu {
position: fixed !important; position: fixed !important;
top: auto !important; top: auto !important;
@@ -939,7 +931,8 @@ td.col-laia-sub {
animation: slideUpSheet 0.3s forwards; animation: slideUpSheet 0.3s forwards;
z-index: 10000 !important; z-index: 10000 !important;
} }
.fc-selection-menu::before { #selectionMenu::before,
#fc-month-selectionMenu::before {
content: ""; content: "";
display: block; display: block;
width: 40px; width: 40px;
@@ -955,3 +948,92 @@ td.col-laia-sub {
gap: 20px !important; gap: 20px !important;
} }
} }
@keyframes slideUpSheet {
to {
transform: translateY(0);
}
}
/* ==========================================================================
AFFINAGE EXTRÊME DES COLONNES MOIS ET SEMAINE (Mise à jour)
========================================================================== */
/* 1. Colonne Mois (Texte vertical) : On réduit à 35px */
.col-month,
#planningTable th.col-sticky-mois,
#planningTable td.col-sticky-mois {
width: 15px !important;
min-width: 15px !important;
max-width: 15px !important;
padding: 2px !important;
}
/* 2. Colonne Semaine (Numéro court) : On réduit à 30px */
#planningTable th.col-sticky-sem,
#planningTable td.col-sticky-sem {
width: 10px !important;
min-width: 10px !important;
max-width: 10px !important;
padding: 2px !important;
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 {
left: 15px !important;
}
/* ==========================================================================
RÉGLAGES MILLIMÉTRÉS (FIX ALIGNEMENTS & STICKY)
========================================================================== */
/* 1. On force un alignement strict pour éviter que les bordures de "Av." et "Use" ne se désalignent */
#planningTable {
table-layout: fixed !important;
width: max-content !important;
min-width: 100% !important;
}
/* 2. Colonne Mois : Box-sizing inclut le padding pour un calcul mathématique exact */
.col-month,
#planningTable th.col-sticky-mois,
#planningTable td.col-sticky-mois {
box-sizing: border-box !important;
width: 25px !important; /* 15px + marges de sécurité */
min-width: 25px !important;
max-width: 25px !important;
padding: 2px !important;
overflow: hidden;
}
/* 3. Colonne Semaine : Le décalage (left) correspond EXACTEMENT à la largeur du Mois */
#planningTable th.col-sticky-sem,
#planningTable td.col-sticky-sem {
box-sizing: border-box !important;
left: 25px !important; /* Doit être rigoureusement égal au width du Mois ! */
width: 20px !important; /* 10px + marges */
min-width: 20px !important;
max-width: 20px !important;
padding: 2px !important;
font-size: 0.8rem !important;
overflow: hidden;
}
/* 4. Sécurité Anti-Trous pour les headers collants du haut (Empêche de voir à travers lors du scroll) */
#planningTable thead tr {
height: 32px !important;
}
#planningTable thead th {
height: 32px !important;
box-sizing: border-box !important;
}
#planningTable thead tr:nth-child(1) th {
top: 0 !important;
}
#planningTable thead tr:nth-child(2) th {
top: 32px !important;
}
#planningTable thead tr:nth-child(3) th {
top: 64px !important;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,194 @@
<?php
require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
require_login();
header('Content-Type: application/json');
$action = $_REQUEST['action'] ?? '';
try {
// ─── 1. RECUPÉRATION COMPLÈTE DES PARAMÈTRES (INITIALISATION) ───
if ($action === 'get_all') {
$stmtFoyer = $pdo->query("SELECT zone_scolaire, care_modes FROM pf_foyer_settings LIMIT 1");
$foyer = $stmtFoyer->fetch(PDO::FETCH_ASSOC) ?: ['zone_scolaire' => 'C', 'care_modes' => '[]'];
$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");
$leavesRaw = $stmtLeaves->fetchAll(PDO::FETCH_ASSOC);
$leavesMap = [];
foreach ($leavesRaw as $leave) {
$leavesMap[$leave['person_id']][] = [
'type' => $leave['leave_type'],
'method' => $leave['method'] ?? 'FIXED',
'allowance' => $leave['allowance'] ?? 0,
'date' => $leave['anniversary_date']
];
}
$stmtSettings = $pdo->query("SELECT setting_key, setting_value FROM pf_settings WHERE module = 'calendar'");
$calendarSettingsRaw = $stmtSettings->fetchAll(PDO::FETCH_KEY_PAIR);
$calendarSettings = [
'calendar_default_view' => $calendarSettingsRaw['calendar_default_view'] ?? 'month',
'calendar_first_day' => $calendarSettingsRaw['calendar_first_day'] ?? '1',
'calendar_working_hours' => $calendarSettingsRaw['calendar_working_hours'] ?? '08:00-19:00'
];
// Catalogue des congés
$stmtLeaveTypes = $pdo->query("SELECT code, label, default_allowance, reset_month, allow_carry_over FROM pf_leave_types ORDER BY label ASC");
$leaveTypes = $stmtLeaveTypes->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'success' => true,
'data' => [
'foyer' => [
'zone_scolaire' => $foyer['zone_scolaire'],
'care_modes' => json_decode($foyer['care_modes'] ?? '[]', true)
],
'people' => $people,
'leaves' => $leavesMap,
'calendar_settings' => $calendarSettings,
'leave_types' => $leaveTypes
]
]);
exit;
}
// ─── 2. SAUVEGARDE DU FOYER (ONGLET 1) ───
if ($action === 'save_foyer') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$zone = trim($_POST['zone_scolaire'] ?? 'C');
$modesRaw = $_POST['care_modes'] ?? '[]';
$modesArray = json_encode(array_values(array_unique(array_filter(json_decode($modesRaw, true)))));
$stmt = $pdo->prepare("UPDATE pf_foyer_settings SET zone_scolaire = ?, care_modes = ? WHERE id = 1");
$stmt->execute([$zone, $modesArray]);
echo json_encode(['success' => true]);
exit;
}
// ─── 3. SAUVEGARDE DES MODES DE GARDE D'UN ENFANT ───
if ($action === 'save_child_care_modes') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$person_id = (int)($_POST['person_id'] ?? 0);
$care_modes = $_POST['care_modes'] ?? '[]';
if ($person_id > 0) {
$stmt = $pdo->prepare("UPDATE pf_people SET care_modes = ? WHERE id = ?");
$stmt->execute([$care_modes, $person_id]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'ID de l\'enfant manquant.']);
}
exit;
}
// ─── 4. GESTION DU CATALOGUE DE CONGÉS (FOYER) ───
if ($action === 'get_leave_types') {
$stmt = $pdo->query("SELECT * FROM pf_leave_types ORDER BY label ASC");
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
if ($action === 'save_leave_type') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$mode = $_POST['mode'] ?? 'add';
$code = strtoupper(trim($_POST['code'] ?? ''));
$label = trim($_POST['label'] ?? '');
if (empty($code) || empty($label)) throw new Exception("Le code et le label sont obligatoires.");
if ($mode === 'add') {
$stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label) VALUES (?, ?)");
$stmt->execute([$code, $label]);
} else {
$stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ? WHERE code = ?");
$stmt->execute([$label, $code]);
}
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_leave_type') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$code = strtoupper(trim($_POST['code'] ?? ''));
if (empty($code)) throw new Exception("Code du congé manquant.");
$pdo->beginTransaction();
// 1. On supprime d'abord ce congé chez tous les membres à qui on l'avait attribué
$stmtMeta = $pdo->prepare("DELETE FROM pf_person_leave_meta WHERE leave_type = ?");
$stmtMeta->execute([$code]);
// 2. Ensuite, on supprime le congé du catalogue global
$stmtCat = $pdo->prepare("DELETE FROM pf_leave_types WHERE code = ?");
$stmtCat->execute([$code]);
$pdo->commit();
echo json_encode(['success' => true]);
exit;
}
// ─── 5. GESTION DES CONGÉS INDIVIDUELS (MEMBRES) ───
if ($action === 'get_person_leaves') {
$personId = (int)($_GET['person_id'] ?? 0);
$stmt = $pdo->prepare("SELECT id, leave_type, allowance, method, anniversary_date FROM pf_person_leave_meta WHERE person_id = ? ORDER BY leave_type ASC");
$stmt->execute([$personId]);
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
if ($action === 'add_person_leave') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$personId = (int)($_POST['person_id'] ?? 0);
$type = strtoupper(trim($_POST['leave_type'] ?? ''));
$allowance = (float)($_POST['allowance'] ?? 0);
$resetMonth = (int)($_POST['reset_month'] ?? 1);
$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]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_person_leave') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$id = (int)($_POST['id'] ?? 0);
$stmt = $pdo->prepare("DELETE FROM pf_person_leave_meta WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
throw new Exception("Action inconnue : " . htmlspecialchars($action));
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
}
@@ -51,3 +51,11 @@
[2026-05-06T20:38:24+02:00] RAW INPUT: [{"date":"2026-05-08","type":"AVIS","duration":1,"person":"Carole"}] [2026-05-06T20:38:24+02:00] RAW INPUT: [{"date":"2026-05-08","type":"AVIS","duration":1,"person":"Carole"}]
[2026-05-06T20:43:43+02:00] RAW INPUT: [{"date":"2026-05-06","type":"AVIS","duration":1,"person":"Carole"}] [2026-05-06T20:43:43+02:00] RAW INPUT: [{"date":"2026-05-06","type":"AVIS","duration":1,"person":"Carole"}]
[2026-05-06T20:44:36+02:00] RAW INPUT: [{"date":"2026-05-06","type":"PEP_SICK","duration":1,"person":"Carole"}] [2026-05-06T20:44:36+02:00] RAW INPUT: [{"date":"2026-05-06","type":"PEP_SICK","duration":1,"person":"Carole"}]
[2026-05-20T16:40:17+02:00] RAW INPUT: [{"date":"2026-05-19","type":"OFF_CAROLE","duration":1,"person":"Carole"}]
[2026-06-16T14:20:35+02:00] RAW INPUT: [{"date":"2026-07-29","type":"CARE_MODE","duration":1,"person":"Centre"}]
[2026-06-16T14:20:43+02:00] RAW INPUT: [{"date":"2026-06-16","type":"CARE_MODE","duration":1,"person":"Centre"}]
[2026-06-16T14:22:51+02:00] RAW INPUT: [{"date":"2026-06-16","type":"CHILD_SICK","duration":1,"person":"5"}]
[2026-06-16T14:23:10+02:00] RAW INPUT: [{"date":"2026-06-16","type":"CHILD_SICK","duration":1,"person":"5"}]
[2026-06-16T14:23:33+02:00] RAW INPUT: [{"date":"2026-06-18","type":"CARE_MODE","duration":1,"person":"Nounou"}]
[2026-06-16T14:23:54+02:00] RAW INPUT: [{"date":"2026-06-11","type":"HELPER_OFF","duration":1,"person":"1"}]
[2026-06-16T14:23:57+02:00] RAW INPUT: [{"date":"2026-06-25","type":"CHILD_SICK","duration":1,"person":"4"}]
@@ -3,13 +3,22 @@ header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php'; require __DIR__ . '/../../../../includes/db.php';
try { try {
// 🔥 LE FIX : On interroge la NOUVELLE table des quotas individuels !
$stmt = $pdo->query(" $stmt = $pdo->query("
SELECT person_id, leave_type, initial_balance, balance_year SELECT
FROM pf_leave_balances person_id,
leave_type AS type,
allowance,
method,
anniversary_date AS date
FROM pf_person_leave_meta
"); ");
$balances = $stmt->fetchAll(PDO::FETCH_ASSOC); $balances = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['balances' => $balances]); echo json_encode([
'status' => 'success',
'balances' => $balances
]);
} catch (PDOException $e) { } catch (PDOException $e) {
http_response_code(500); http_response_code(500);
echo json_encode([ echo json_encode([
@@ -17,3 +26,4 @@ try {
'message' => $e->getMessage(), 'message' => $e->getMessage(),
]); ]);
} }
?>
@@ -8,77 +8,98 @@ $action = $input['action'] ?? '';
if (!$action) { if (!$action) {
http_response_code(400); http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Action manquante.']); echo json_encode(['success' => false, 'status' => 'error', 'message' => 'Action manquante.']);
exit; exit;
} }
try { try {
// --- SUPPRESSION UNITAIRE ---
if ($action === 'delete') { if ($action === 'delete') {
$eventId = (int)($input['event_id'] ?? 0); $eventId = (int)($input['event_id'] ?? 0);
if ($eventId <= 0) { if ($eventId <= 0) {
http_response_code(400); http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'ID manquant.']); echo json_encode(['success' => false, 'status' => 'error', 'message' => 'ID manquant.']);
exit; exit;
} }
$stmt = $pdo->prepare("DELETE FROM pf_events WHERE id = ?"); $stmt = $pdo->prepare("DELETE FROM pf_events WHERE id = ?");
$stmt->execute([$eventId]); $stmt->execute([$eventId]);
echo json_encode(['status' => 'success']); echo json_encode(['success' => true, 'status' => 'success']);
exit; exit;
} }
// --- MISE À JOUR UNITAIRE ---
if ($action === 'update') { if ($action === 'update') {
$eventId = (int)($input['event_id'] ?? 0); $eventId = (int)($input['event_id'] ?? 0);
$newType = $input['new_type'] ?? ''; $newType = $input['new_type'] ?? '';
if ($eventId <= 0 || !$newType) { if ($eventId <= 0 || !$newType) {
http_response_code(400); http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Données manquantes.']); echo json_encode(['success' => false, 'status' => 'error', 'message' => 'Données manquantes.']);
exit; exit;
} }
$stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id = ?"); $stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id = ?");
$stmt->execute([$newType, $eventId]); $stmt->execute([$newType, $eventId]);
echo json_encode(['status' => 'success']); echo json_encode(['success' => true, 'status' => 'success']);
exit;
}
if ($action === 'bulk_delete_day_types_person') {
$dates = $input['dates'] ?? [];
$types = $input['types'] ?? [];
$person_id = $input['person_id'] ?? null;
$dates = array_filter($dates, function($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); });
if (empty($dates) || empty($types)) {
echo json_encode(['success' => true, 'status' => 'success']);
exit;
}
$datePlaceholders = implode(',', array_fill(0, count($dates), '?'));
$typePlaceholders = implode(',', array_fill(0, count($types), '?'));
$sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders) AND event_type IN ($typePlaceholders)";
$params = array_merge($dates, $types);
// 🔥 LE CORRECTIF : On cherche le 0 en base de données
if ($person_id === null || $person_id === '' || (int)$person_id === 0) {
$sql .= " AND person_id = 0";
} else {
$sql .= " AND person_id = ?";
$params[] = (int)$person_id;
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
echo json_encode(['success' => true, 'status' => 'success']);
exit; exit;
} }
// --- SUPPRESSION DE MASSE (Par date et type) ---
// Utilisé quand on ajoute un événement pour nettoyer les doublons potentiels (ex: Off vs Extra)
if ($action === 'bulk_delete_day_types') { if ($action === 'bulk_delete_day_types') {
$dates = $input['dates'] ?? []; $dates = $input['dates'] ?? [];
$types = $input['types'] ?? []; $types = $input['types'] ?? [];
$dates = array_filter($dates, function($d) { $dates = array_filter($dates, function($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); });
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
});
if (empty($dates) || empty($types)) { if (empty($dates) || empty($types)) {
echo json_encode(['status' => 'success']); echo json_encode(['success' => true, 'status' => 'success']);
exit; exit;
} }
// Création des placeholders IN (?,?,?)
$datePlaceholders = implode(',', array_fill(0, count($dates), '?')); $datePlaceholders = implode(',', array_fill(0, count($dates), '?'));
$typePlaceholders = implode(',', array_fill(0, count($types), '?')); $typePlaceholders = implode(',', array_fill(0, count($types), '?'));
$sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders) AND event_type IN ($typePlaceholders)"; $sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders) AND event_type IN ($typePlaceholders)";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
// Fusion des tableaux pour l'exécution
$stmt->execute(array_merge($dates, $types)); $stmt->execute(array_merge($dates, $types));
echo json_encode(['status' => 'success']); echo json_encode(['success' => true, 'status' => 'success']);
exit; exit;
} }
// --- SUPPRESSION TOTALE SUR DES DATES ---
if ($action === 'bulk_delete_all') { if ($action === 'bulk_delete_all') {
$dates = $input['dates'] ?? []; $dates = $input['dates'] ?? [];
$dates = array_filter($dates, function($d) { $dates = array_filter($dates, function($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); });
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
});
if (empty($dates) || empty($types)) { if (empty($dates)) {
echo json_encode(['status' => 'success']); echo json_encode(['success' => true, 'status' => 'success']);
exit; exit;
} }
@@ -87,15 +108,15 @@ try {
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->execute($dates); $stmt->execute($dates);
echo json_encode(['status' => 'success']); echo json_encode(['success' => true, 'status' => 'success']);
exit; exit;
} }
// Action inconnue
http_response_code(400); http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Action non reconnue : ' . $action]); echo json_encode(['success' => false, 'status' => 'error', 'message' => 'Action non reconnue : ' . $action]);
} catch (PDOException $e) { } catch (PDOException $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); echo json_encode(['success' => false, 'status' => 'error', 'message' => $e->getMessage()]);
} }
?>
@@ -1,33 +1,14 @@
<?php <?php
// includes/api/save-events.php // modules/family-calendar/includes/api/save-events.php
header('Content-Type: application/json'); header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php'; require_once __DIR__ . '/../../../../includes/db.php';
$rawInput = file_get_contents('php://input');
$eventsToSave = json_decode($rawInput, true);
// Optionnel : tu peux commenter/supprimer ces logs en production pour économiser du disque
file_put_contents(
__DIR__ . '/events-debug.log',
"[" . date('c') . "] RAW INPUT: " . $rawInput . PHP_EOL,
FILE_APPEND
);
if (empty($eventsToSave) || !is_array($eventsToSave)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Aucune donnée d\'événement reçue.']);
exit;
}
$inserted = [];
try { try {
// 1. OPTIMISATION : On récupère toutes les personnes d'un coup (Mapping) $rawInput = file_get_contents('php://input');
$stmtPeople = $pdo->query("SELECT id, name FROM pf_people"); $eventsToSave = json_decode($rawInput, true);
$peopleMap = [];
while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) { if (empty($eventsToSave) || !is_array($eventsToSave)) {
// On crée un tableau associatif : ['Carole' => 1, 'Alex' => 2, etc.] throw new Exception("Aucune donnée d'événement reçue.");
$peopleMap[$row['name']] = $row['id'];
} }
$pdo->beginTransaction(); $pdo->beginTransaction();
@@ -36,26 +17,36 @@ try {
VALUES (:event_date, :event_type, :person_id, :duration)"; VALUES (:event_date, :event_type, :person_id, :duration)";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
foreach ($eventsToSave as $event) { $inserted = [];
$person_id = null;
// 2. On vérifie simplement dans notre tableau (plus de requête SQL ici !) foreach ($eventsToSave as $event) {
if (!empty($event['person']) && isset($peopleMap[$event['person']])) { if (empty($event['date']) || empty($event['type'])) {
$person_id = $peopleMap[$event['person']]; continue; // Sécurité anti-crash
} }
$person_id = 0;
if (!empty($event['person_id']) && is_numeric($event['person_id'])) {
$person_id = (int)$event['person_id'];
} elseif (!empty($event['person']) && is_numeric($event['person'])) {
$person_id = (int)$event['person'];
}
// 🔥 LE FIX : On tronque à 50 caractères pour éviter l'erreur "Data truncated"
$safeType = substr(trim($event['type']), 0, 50);
$duration = isset($event['duration']) ? (float)$event['duration'] : 1.0;
$stmt->execute([ $stmt->execute([
':event_date' => $event['date'], ':event_date' => $event['date'],
':event_type' => $event['type'], ':event_type' => $safeType,
':person_id' => $person_id, ':person_id' => $person_id,
':duration' => $event['duration'] ?? 1.0, ':duration' => $duration,
]); ]);
$inserted[] = [ $inserted[] = [
'id' => $pdo->lastInsertId(), 'id' => $pdo->lastInsertId(),
'date' => $event['date'], 'date' => $event['date'],
'type' => $event['type'], 'type' => $safeType,
'duration' => $event['duration'] ?? 1.0, 'duration' => $duration,
'person_id' => $person_id, 'person_id' => $person_id,
]; ];
} }
@@ -63,12 +54,20 @@ try {
$pdo->commit(); $pdo->commit();
echo json_encode([ echo json_encode([
'success' => true,
'status' => 'success', 'status' => 'success',
'inserted' => $inserted, 'inserted' => $inserted,
]); ]);
} catch (Exception $e) { } catch (\Throwable $e) { // 🔥 LE FIX : \Throwable attrape AUSSI les fatals errors PDO
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack(); $pdo->rollBack();
}
http_response_code(500); http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'Erreur lors de la sauvegarde : ' . $e->getMessage()]); echo json_encode([
'success' => false,
'status' => 'error',
'message' => 'Erreur Serveur/SQL : ' . $e->getMessage()
]);
} }
?>
+64 -7
View File
@@ -108,15 +108,38 @@ if ($action === 'vehicles') {
gOk($stmt->fetchAll()); gOk($stmt->fetchAll());
} }
if ($method === 'POST') { if ($method === 'POST') {
$photo = handleUpload('photo', $UPLOAD_DIR); $d = $_POST ?: gBody(); $photo = handleUpload('photo', $UPLOAD_DIR);
$stmt = $pdo->prepare("INSERT INTO pf_vehicles (name,brand,model,year,license_plate,vin,fuel_type,color,purchase_date,purchase_price,current_km,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"); $d = $_POST ?: gBody();
$stmt->execute([$d['name']??'', $d['brand']??'', $d['model']??'', $d['year']??null, $d['license_plate']??null, $d['vin']??null, $d['fuel_type']??'Essence', $d['color']??null, $d['purchase_date']??null, $d['purchase_price']??null, $d['current_km']??0, $photo, $d['notes']??null]);
// 🔥 LE FIX EST ICI : Fonctions boucliers pour MySQL Strict Mode
$nullInt = fn($v) => ($v === '' || $v === null) ? null : (int)$v;
$nullFloat = fn($v) => ($v === '' || $v === null) ? null : (float)$v;
$nullStr = fn($v) => ($v === '' || $v === null) ? null : $v;
$stmt = $pdo->prepare("INSERT INTO pf_vehicles (name,brand,model,year,license_plate,vin,fuel_type,consumption,color,purchase_date,purchase_price,current_km,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->execute([
$d['name'] ?? '',
$d['brand'] ?? '',
$d['model'] ?? '',
$nullInt($d['year'] ?? null),
$nullStr($d['license_plate'] ?? null),
$nullStr($d['vin'] ?? null),
$d['fuel_type'] ?? 'Essence',
$nullFloat($d['consumption'] ?? null),
$nullStr($d['color'] ?? null),
$nullStr($d['purchase_date'] ?? null),
$nullFloat($d['purchase_price'] ?? null),
$nullInt($d['current_km'] ?? null) ?? 0,
$photo,
$nullStr($d['notes'] ?? null)
]);
gOk(['id' => $pdo->lastInsertId()]); gOk(['id' => $pdo->lastInsertId()]);
} }
if ($method === 'PUT') { if ($method === 'PUT') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody(); $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
$int_fields = ['year','current_km']; $float_fields = ['purchase_price']; $int_fields = ['year','current_km'];
$fields = ['name','brand','model','year','license_plate','vin','fuel_type','color','purchase_date','purchase_price','current_km','notes']; $float_fields = ['purchase_price', 'consumption'];
$fields = ['name','brand','model','year','license_plate','vin','fuel_type','consumption','color','purchase_date','purchase_price','current_km','notes'];
$sets = array_map(fn($f) => "$f = ?", $fields); $sets = array_map(fn($f) => "$f = ?", $fields);
$vals = array_map(function($f) use ($d, $int_fields, $float_fields) { $vals = array_map(function($f) use ($d, $int_fields, $float_fields) {
$v = $d[$f] ?? null; $v = $d[$f] ?? null;
@@ -259,7 +282,23 @@ if ($action === 'maintenances') {
$photo = handleUpload('invoice_photo', $UPLOAD_DIR); $d = $_POST ?: gBody(); $photo = handleUpload('invoice_photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
if (!($d['vehicle_id'] ?? null)) gErr('vehicle_id manquant'); if (!($d['vehicle_id'] ?? null)) gErr('vehicle_id manquant');
$stmt = $pdo->prepare("INSERT INTO pf_maintenances (vehicle_id,type,description,date,km,cost,mechanic,garage_name,next_km,next_date,invoice_photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); $stmt = $pdo->prepare("INSERT INTO pf_maintenances (vehicle_id,type,description,date,km,cost,mechanic,garage_name,next_km,next_date,invoice_photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->execute([$d['vehicle_id'], $d['type']??'', $d['description']??null, $d['date']??date('Y-m-d'), $d['km']??null, $d['cost']??0, $d['mechanic']??null, $d['garage_name']??null, $d['next_km']??null, $d['next_date']??null, $photo, $d['notes']??null]); $nullInt = fn($v) => ($v === '' || $v === null) ? null : (int)$v;
$nullFloat = fn($v) => ($v === '' || $v === null) ? null : (float)$v;
$nullStr = fn($v) => ($v === '' || $v === null) ? null : $v;
$stmt->execute([
$d['vehicle_id'],
$d['type'] ?? '',
$nullStr($d['description'] ?? null),
$d['date'] ?? date('Y-m-d'),
$nullInt($d['km'] ?? null),
$nullFloat($d['cost'] ?? null),
$nullStr($d['mechanic'] ?? null),
$nullStr($d['garage_name'] ?? null),
$nullInt($d['next_km'] ?? null),
$nullStr($d['next_date'] ?? null),
$photo,
$nullStr($d['notes'] ?? null),
]);
$mid = $pdo->lastInsertId(); $mid = $pdo->lastInsertId();
if (!empty($d['km'])) { $pdo->prepare("UPDATE pf_vehicles SET current_km = GREATEST(current_km, ?), updated_at = NOW() WHERE id = ?")->execute([$d['km'], $d['vehicle_id']]); } if (!empty($d['km'])) { $pdo->prepare("UPDATE pf_vehicles SET current_km = GREATEST(current_km, ?), updated_at = NOW() WHERE id = ?")->execute([$d['km'], $d['vehicle_id']]); }
gOk(['id' => $mid]); gOk(['id' => $mid]);
@@ -307,8 +346,26 @@ if ($action === 'parts') {
} }
if ($method === 'POST') { if ($method === 'POST') {
$photo = handleUpload('photo', $UPLOAD_DIR); $d = $_POST ?: gBody(); $photo = handleUpload('photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
$nullInt = fn($v) => ($v === '' || $v === null) ? null : (int)$v;
$nullFloat = fn($v) => ($v === '' || $v === null) ? null : (float)$v;
$nullStr = fn($v) => ($v === '' || $v === null) ? null : $v;
$stmt = $pdo->prepare("INSERT INTO pf_parts (vehicle_id,maintenance_id,brand,reference,name,category,price,quantity,unit,supplier,purchase_date,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"); $stmt = $pdo->prepare("INSERT INTO pf_parts (vehicle_id,maintenance_id,brand,reference,name,category,price,quantity,unit,supplier,purchase_date,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->execute([$d['vehicle_id']??null, $d['maintenance_id']??null, $d['brand']??null, $d['reference']??null, $d['name']??'', $d['category']??'Autre', $d['price']??0, $d['quantity']??1, $d['unit']??'pièce', $d['supplier']??null, $d['purchase_date']??null, $photo, $d['notes']??null]); $stmt->execute([
$nullInt($d['vehicle_id'] ?? null),
$nullInt($d['maintenance_id'] ?? null),
$nullStr($d['brand'] ?? null),
$nullStr($d['reference'] ?? null),
$d['name'] ?? '',
$d['category'] ?? 'Autre',
$nullFloat($d['price'] ?? null) ?? 0,
$nullInt($d['quantity'] ?? null) ?? 1,
$d['unit'] ?? 'pièce',
$nullStr($d['supplier'] ?? null),
$nullStr($d['purchase_date'] ?? null),
$photo,
$nullStr($d['notes'] ?? null)
]);
gOk(['id' => $pdo->lastInsertId()]); gOk(['id' => $pdo->lastInsertId()]);
} }
if ($method === 'PUT') { if ($method === 'PUT') {
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
<?php
require __DIR__ . '/../../includes/auth.php';
require __DIR__ . '/../../includes/db.php';
require_login();
header('Content-Type: application/json');
$action = $_POST['action'] ?? $_GET['action'] ?? '';
try {
if ($action === 'get_all') {
$occ = $pdo->query("SELECT * FROM pf_gift_occasions ORDER BY sort_order ASC, id ASC")->fetchAll();
$proches = $pdo->query("SELECT * FROM pf_people WHERE role IN ('proche_adulte', 'proche_enfant') ORDER BY name ASC")->fetchAll();
echo json_encode(['success' => true, 'data' => ['occasions' => $occ, 'proches' => $proches]]);
exit;
}
if ($action === 'save_occasion') {
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$name = trim($_POST['name']);
$icon = trim($_POST['icon']) ?: '🎁';
$month_date = trim($_POST['month_date']) ?: null;
if (empty($name)) throw new Exception("Le nom est obligatoire.");
if ($id) {
$stmt = $pdo->prepare("UPDATE pf_gift_occasions SET name=?, icon=?, month_date=? WHERE id=?");
$stmt->execute([$name, $icon, $month_date, $id]);
} else {
$code = 'CUST_' . strtoupper(substr(preg_replace('/[^A-Za-z0-9]/', '', $name), 0, 4)) . '_' . rand(100, 999);
$stmt = $pdo->prepare("INSERT INTO pf_gift_occasions (code, name, icon, month_date) VALUES (?, ?, ?, ?)");
$stmt->execute([$code, $name, $icon, $month_date]);
}
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_occasion') {
$id = (int)$_POST['id'];
$pdo->prepare("DELETE FROM pf_gift_occasions WHERE id=?")->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'toggle_occasion') {
$id = (int)$_POST['id'];
$state = (int)$_POST['state'];
$pdo->prepare("UPDATE pf_gift_occasions SET is_active=? WHERE id=?")->execute([$state, $id]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'save_proche') {
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$name = trim($_POST['name']);
$role = $_POST['role'];
if (empty($name)) throw new Exception("Le nom est obligatoire.");
if (!in_array($role, ['proche_adulte', 'proche_enfant'])) throw new Exception("Rôle invalide.");
if ($id) {
// Sécurité : on s'assure qu'on modifie bien un proche et pas un parent
$stmt = $pdo->prepare("UPDATE pf_people SET name=?, role=? WHERE id=? AND role IN ('proche_adulte', 'proche_enfant')");
$stmt->execute([$name, $role, $id]);
} else {
$stmt = $pdo->prepare("INSERT INTO pf_people (name, role, color) VALUES (?, ?, '#94a3b8')");
$stmt->execute([$name, $role]);
}
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_proche') {
$id = (int)$_POST['id'];
$stmt = $pdo->prepare("DELETE FROM pf_people WHERE id=? AND role IN ('proche_adulte', 'proche_enfant')");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
}
+243 -91
View File
@@ -6,7 +6,6 @@
margin: 0; margin: 0;
color: var(--text-main); color: var(--text-main);
} }
.cl-titlebar { .cl-titlebar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -17,7 +16,6 @@
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light);
padding-bottom: 15px; padding-bottom: 15px;
} }
.cl-view-switch { .cl-view-switch {
display: flex; display: flex;
padding: 4px; padding: 4px;
@@ -25,7 +23,6 @@
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.cl-view-btn { .cl-view-btn {
padding: 6px 16px; padding: 6px 16px;
border-radius: 6px; border-radius: 6px;
@@ -35,13 +32,12 @@
color: var(--text-muted); color: var(--text-muted);
transition: 0.2s; transition: 0.2s;
} }
.cl-view-btn.is-active { .cl-view-btn.is-active {
background: var(--text-main); background: var(--text-main);
color: var(--bg-panel); color: var(--bg-panel);
} }
/* === FILTRES STICKY === */ /* === FILTRES === */
.pf-filter-bar { .pf-filter-bar {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -52,7 +48,6 @@
z-index: 50; z-index: 50;
flex-wrap: wrap; flex-wrap: wrap;
} }
.pf-filter-label { .pf-filter-label {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
@@ -62,36 +57,6 @@
gap: 6px; gap: 6px;
} }
.pf-filter-select {
padding: 6px 12px;
border-radius: 20px; /* Forme de pillule */
border: 1px solid var(--border-light);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
font-size: 0.85rem;
font-weight: 600;
color: var(--text-main);
outline: none;
font-family: inherit;
cursor: pointer;
box-shadow: var(--shadow-sm);
width: auto; /* Empêche de prendre toute la largeur */
appearance: none; /* Nettoie la flèche système... */
-webkit-appearance: none;
-moz-appearance: none;
/* ...pour mettre une flèche personnalisée plus discrète */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px;
padding-right: 28px; /* Place pour la flèche */
}
.pf-filter-select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
/* === SECTIONS ENFANTS === */ /* === SECTIONS ENFANTS === */
.pf-child-section { .pf-child-section {
margin-bottom: 30px; margin-bottom: 30px;
@@ -121,10 +86,10 @@
padding-bottom: 12px; padding-bottom: 12px;
margin-bottom: 15px; margin-bottom: 15px;
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light);
scrollbar-width: none; /* Firefox */ scrollbar-width: none;
} }
.pf-child-totals-bar::-webkit-scrollbar { .pf-child-totals-bar::-webkit-scrollbar {
display: none; /* Chrome/Safari */ display: none;
} }
.pf-summary-pill { .pf-summary-pill {
white-space: nowrap; white-space: nowrap;
@@ -182,12 +147,6 @@
color: var(--success); color: var(--success);
font-size: 1rem; font-size: 1rem;
} }
.pf-gift-badges-col {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.pf-pill-adult { .pf-pill-adult {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
@@ -196,14 +155,6 @@
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
} }
.pf-pill-occ {
font-size: 0.65rem;
font-weight: 700;
background: #fef3c7;
color: #b45309;
padding: 2px 6px;
border-radius: 4px;
}
.pf-gift-payer { .pf-gift-payer {
font-size: 0.7rem; font-size: 0.7rem;
color: #ef4444; color: #ef4444;
@@ -241,53 +192,76 @@
z-index: 2; z-index: 2;
border-right: 2px solid var(--border-light); border-right: 2px solid var(--border-light);
} }
.gift-tricount-section {
/* Icônes des fêtes (Tió, Noël, etc.) plus petites et bien alignées */ border: 1px solid var(--border-light);
.cl-occasion-icon { margin-top: 40px;
width: 20px; /* Réduit pour être discret */ }
height: 20px; .gift-tricount-title {
object-fit: contain; margin-top: 0;
vertical-align: middle; color: var(--text-main);
font-size: 1.3rem;
}
.gift-tricount-success {
color: var(--success);
font-weight: 700;
margin-bottom: 0;
}
.gift-tricount-debt-amount {
color: var(--danger);
}
.gift-matrix-details {
margin-top: 20px;
padding: 12px;
border-radius: 8px;
background: var(--bg-panel);
border: 1px solid var(--border-light);
}
.gift-matrix-summary {
cursor: pointer;
font-weight: 600;
color: var(--text-muted);
outline: none;
}
.gift-matrix-wrapper {
overflow-x: auto;
margin-top: 15px;
}
.cl-debt-matrix th.sticky-col.bg-header {
background: var(--bg-page);
}
.cl-debt-matrix th.sticky-col.bg-body {
background: var(--bg-panel);
}
.gift-cell-danger {
color: var(--danger);
font-weight: 700;
background: var(--bg-danger-soft);
}
.gift-cell-muted {
color: var(--text-muted);
} }
/* === TYPOGRAPHIE & ICONES === */
.cl-occasion-icon {
font-size: 1.4rem;
margin-right: 8px;
}
.cl-occasion-title { .cl-occasion-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; /* Espace réduit entre l'icône et le texte */ }
.gift-empty-state {
color: var(--text-muted);
font-size: 0.9rem;
font-style: italic;
margin: 0;
} }
@media (max-width: 768px) {
.pf-child-section {
padding: 15px;
}
.pf-filter-bar {
padding: 0 5px;
}
.pf-filter-label {
display: none;
}
.pf-gift-feed {
grid-template-columns: 1fr 1fr;
}
.pf-gift-title {
font-size: 0.85rem;
}
.pf-gift-price {
font-size: 0.9rem;
}
}
@media (max-width: 400px) {
.pf-gift-feed {
grid-template-columns: 1fr;
}
}
/* === COMPOSANT MULTI-SELECT VANILLA === */ /* === COMPOSANT MULTI-SELECT VANILLA === */
.pf-multi-select { .pf-multi-select {
position: relative; position: relative;
display: inline-block; display: inline-block;
} }
/* Le bouton déclencheur (réutilise le style des filtres) */
.pf-ms-trigger { .pf-ms-trigger {
padding: 6px 16px; padding: 6px 16px;
border-radius: 20px; border-radius: 20px;
@@ -310,8 +284,6 @@
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
} }
/* Le menu déroulant */
.pf-ms-dropdown { .pf-ms-dropdown {
display: none; display: none;
position: absolute; position: absolute;
@@ -333,8 +305,6 @@
display: flex; display: flex;
animation: popDown 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; animation: popDown 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
} }
/* Les options (checkboxes) */
.pf-ms-option { .pf-ms-option {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -375,3 +345,185 @@
transform: translateY(0); transform: translateY(0);
} }
} }
@media (max-width: 768px) {
.pf-child-section {
padding: 15px;
}
.pf-filter-bar {
padding: 0 5px;
}
.pf-filter-label {
display: none;
}
.pf-gift-feed {
grid-template-columns: 1fr 1fr;
}
.pf-gift-title {
font-size: 0.85rem;
}
.pf-gift-price {
font-size: 0.9rem;
}
}
@media (max-width: 400px) {
.pf-gift-feed {
grid-template-columns: 1fr;
}
}
/* === MODALE EDITION CADEAU === */
.gift-modal-custom-content {
max-width: 500px;
width: 95%;
}
.gift-modal-custom-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.gift-modal-custom-title {
margin: 0;
border: none;
padding: 0;
}
.gift-modal-close-btn {
background: none;
border: none;
font-size: 1.8rem;
cursor: pointer;
color: var(--text-muted);
line-height: 1;
}
.gift-form-group-spaced {
margin-bottom: 25px;
}
.gift-modal-custom-footer {
padding-top: 15px;
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid var(--border-light);
}
.gl-pane {
display: none;
animation: fadeIn 0.3s ease;
}
.gl-pane.active {
display: block;
}
.gl-pane-title {
margin-top: 0;
color: var(--text-main);
font-size: 1.1rem;
margin-bottom: 10px;
}
.gl-pane-desc {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 15px;
}
.gl-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--bg-panel);
border-radius: var(--radius);
border: 1px solid var(--border-light);
margin-bottom: 8px;
}
.gl-form-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: flex-end;
margin-top: 15px;
}
.gl-divider {
margin: 1.5rem 0;
border: 0;
border-bottom: 1px solid var(--border-light);
}
.gl-input-sm {
flex: 0 0 60px;
}
.gl-input-md {
flex: 1;
min-width: 120px;
}
.gift-loader {
text-align: center;
padding: 20px;
color: var(--text-muted);
font-style: italic;
}
.align-center {
align-items: center;
}
/* =========================================
COMPOSANTS MODALE & EDITION IN-PLACE
========================================= */
/* Sélecteur d'émojis horizontal */
.emoji-picker {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.emoji-option {
cursor: pointer;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-light);
border-radius: 8px;
font-size: 1.4rem;
transition: all 0.2s;
background: transparent;
user-select: none;
}
.emoji-option:hover {
background: var(--bg-page);
border-color: #cbd5e1;
}
.emoji-option.selected {
border-color: var(--primary);
background: var(
--bg-subtle
); /* ou un bleu très clair rgba(59, 130, 246, 0.1) */
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
/* Alignement des boutons d'ajout */
.modal-actions-right {
display: flex;
gap: 10px;
justify-content: flex-end;
width: 100%;
margin-top: 10px;
padding-top: 15px;
border-top: 1px solid var(--border-light);
}
/* Édition In-Place sur les lignes de la liste */
.inline-edit-form {
display: flex;
gap: 8px;
align-items: center;
background: var(--bg-subtle);
padding: 8px 12px;
border-radius: 8px;
width: 100%;
flex-wrap: wrap; /* Évite que ça dépasse sur mobile */
}
.inline-edit-form .pf-input {
margin: 0;
}
File diff suppressed because it is too large Load Diff
+1633 -321
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
<?php
// modules/holidays/includes/api/delete_attachment.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;
}
$file_id = isset($_POST['file_id']) ? (int)$_POST['file_id'] : 0;
$holiday_id = isset($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : 0;
if ($file_id === 0 || $holiday_id === 0) {
echo json_encode(['success' => false, 'error' => 'Paramètres manquants']);
exit;
}
// 1. On récupère le chemin exact du fichier pour le supprimer du NAS
$stmt = $pdo->prepare("SELECT file_path FROM pf_holidays_attachments WHERE id = ? AND holiday_id = ?");
$stmt->execute([$file_id, $holiday_id]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if ($file) {
$absolutePath = dirname(__DIR__, 4) . '/' . $file['file_path'];
// 2. On supprime physiquement le fichier s'il existe
if (file_exists($absolutePath)) {
unlink($absolutePath);
}
// 3. On nettoie la base de données
$del = $pdo->prepare("DELETE FROM pf_holidays_attachments WHERE id = ?");
$del->execute([$file_id]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Fichier introuvable ou non autorisé']);
}
@@ -0,0 +1,22 @@
<?php
// modules/holidays/includes/api/get_attachments.php
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
header('Content-Type: application/json');
$holiday_id = isset($_GET['holiday_id']) ? (int)$_GET['holiday_id'] : 0;
$item_id = isset($_GET['item_id']) ? (int)$_GET['item_id'] : 0;
if ($holiday_id === 0) {
echo json_encode(['success' => false, 'error' => 'ID manquant']);
exit;
}
// On récupère les documents de cette étape spécifique
$stmt = $pdo->prepare("SELECT id, file_name, file_path FROM pf_holidays_attachments WHERE holiday_id = ? AND item_id = ? ORDER BY uploaded_at DESC");
$stmt->execute([$holiday_id, $item_id]);
$files = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'files' => $files]);
+122 -19
View File
@@ -4,32 +4,117 @@ require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php'; require dirname(__DIR__, 4) . '/includes/db.php';
require_login(); require_login();
// INTERCEPTION AJAX : Sauvegarde du planning (Drag & Drop / Durée) // ---------------------------------------------------------------------------
if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration'])) { // INTERCEPTIONS AJAX (Exécution ultra rapide sans rechargement lourd)
$itemId = (int)$_POST['item_id']; // ---------------------------------------------------------------------------
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();
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'];
$stmt = $pdo->prepare("SELECT location_name, lat, lng, step_start_date, step_end_date, step_type FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ? LIMIT 1");
$stmt->execute([$holiday_id, $sort_order]);
$stepInfo = $stmt->fetch();
if ($stepInfo) {
$dur = isset($_POST['duration']) ? (int)$_POST['duration'] : 1;
$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, $category, $name, $amount, 0,
$stepInfo['location_name'], $stepInfo['lat'], $stepInfo['lng'],
$sort_order, $stepInfo['step_start_date'], $stepInfo['step_end_date'],
$stepInfo['step_type'], $context, $dur, $date, $time
]);
$newId = $pdo->lastInsertId();
$pdo->commit();
echo json_encode(['success' => true, 'id' => $newId]);
exit;
}
echo json_encode(['success' => false, 'error' => 'Etape introuvable']);
exit;
}
// Drag & Drop planning : Date et Heure
if ($_POST['action'] === 'update_item_datetime') { if ($_POST['action'] === 'update_item_datetime') {
$itemId = (int)$_POST['item_id'];
$itemDate = !empty($_POST['item_date']) ? $_POST['item_date'] : null; $itemDate = !empty($_POST['item_date']) ? $_POST['item_date'] : null;
$itemTime = !empty($_POST['item_time']) ? $_POST['item_time'] : null; $itemTime = !empty($_POST['item_time']) ? $_POST['item_time'] : null;
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET item_date = ?, item_time = ? WHERE id = ?"); $stmt = $pdo->prepare("UPDATE pf_holidays_items SET item_date = ?, item_time = ? WHERE id = ?");
$stmt->execute([$itemDate, $itemTime, $itemId]); $stmt->execute([$itemDate, $itemTime, $itemId]);
} else { echo json_encode(['success' => true]);
exit;
}
// Changement de durée (+/-)
if ($_POST['action'] === 'update_item_duration') {
$itemId = (int)$_POST['item_id'];
$duration = (int)$_POST['duration']; $duration = (int)$_POST['duration'];
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET duration = ? WHERE id = ?"); $stmt = $pdo->prepare("UPDATE pf_holidays_items SET duration = ? WHERE id = ?");
$stmt->execute([$duration, $itemId]); $stmt->execute([$duration, $itemId]);
echo json_encode(['success' => true]);
exit;
} }
echo json_encode(['success' => true]); } catch (Exception $e) {
exit; // Crucial : on arrête le script ici ! if ($pdo->inTransaction()) { $pdo->rollBack(); }
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
}
} }
// --------------------------------===========================================
// FORMULAIRE TRADITIONNEL (Soumission de la modale d'étape)
// --------------------------------===========================================
$holiday_id = (int)$_POST['holiday_id']; $holiday_id = (int)$_POST['holiday_id'];
$location_name = trim($_POST['location_name']); $location_name = trim($_POST['location_name']);
$lat = (float)$_POST['lat']; $lat = (float)$_POST['lat'];
$lng = (float)$_POST['lng']; $lng = (float)$_POST['lng'];
$old_sort_order = isset($_POST['old_sort_order']) && $_POST['old_sort_order'] !== '' ? (int)$_POST['old_sort_order'] : null; $old_sort_order = isset($_POST['old_sort_order']) && $_POST['old_sort_order'] !== '' ? (int)$_POST['old_sort_order'] : null;
// 1. SUPPRESSION // SUPPRESSION DE L'ÉTAPE
if (isset($_POST['action_delete']) && $_POST['action_delete'] === '1') { if (isset($_POST['action_delete']) && $_POST['action_delete'] === '1') {
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]); $pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]);
header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id); header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id);
@@ -40,26 +125,34 @@ if ($holiday_id > 0 && !empty($location_name)) {
try { try {
$pdo->beginTransaction(); $pdo->beginTransaction();
// 2. DÉTERMINER L'ORDRE (Identifiant de l'étape)
if ($old_sort_order !== null) { if ($old_sort_order !== null) {
// Modification : on supprime l'ancien contenu de cette étape précise
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]); $pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]);
$target_order = $old_sort_order; $target_order = $old_sort_order;
} else { } else {
// Ajout : on place à la fin (Max + 1) // 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 = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
$stmtMax->execute([$holiday_id]); $stmtMax->execute([$holiday_id]);
$max = $stmtMax->fetchColumn(); $max = $stmtMax->fetchColumn();
$target_order = ($max !== null) ? (int)$max + 1 : 0; $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]);
}
} }
// Récupération des dates de l'étape globale
$step_start = !empty($_POST['step_start_date']) ? $_POST['step_start_date'] : null; $step_start = !empty($_POST['step_start_date']) ? $_POST['step_start_date'] : null;
$step_end = !empty($_POST['step_end_date']) ? $_POST['step_end_date'] : null; $step_end = !empty($_POST['step_end_date']) ? $_POST['step_end_date'] : null;
$is_return = isset($_POST['is_return']) ? 1 : 0; // NOUVEAU $step_type = $_POST['step_type'] ?? 'stop';
// 3. INSERTION DES LIGNES (16 Colonnes) if ($step_type === 'origin' || $step_type === 'destination') {
$stmt = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng, sort_order, notes, item_date, item_time, step_start_date, step_end_date, duration, is_return) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); $step_end = null;
}
$stmt = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng, sort_order, notes, item_date, item_time, step_start_date, step_end_date, duration, step_type, expense_context) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$validItemsCount = 0; $validItemsCount = 0;
if (isset($_POST['items']['name'])) { if (isset($_POST['items']['name'])) {
@@ -75,19 +168,26 @@ if ($holiday_id > 0 && !empty($location_name)) {
$date = !empty($_POST['items']['date'][$i]) ? $_POST['items']['date'][$i] : null; $date = !empty($_POST['items']['date'][$i]) ? $_POST['items']['date'][$i] : null;
$time = !empty($_POST['items']['time'][$i]) ? $_POST['items']['time'][$i] : null; $time = !empty($_POST['items']['time'][$i]) ? $_POST['items']['time'][$i] : null;
$dur = !empty($_POST['items']['duration'][$i]) ? (int)$_POST['items']['duration'][$i] : 1; $dur = !empty($_POST['items']['duration'][$i]) ? (int)$_POST['items']['duration'][$i] : 1;
$context = !empty($_POST['items']['context'][$i]) ? $_POST['items']['context'][$i] : 'local';
// Ajout de $is_return à la fin // Récupération des coordonnées spécifiques à l'activité (ou fallback sur l'étape)
$stmt->execute([$holiday_id, $cat, $name ?: tr('hdl_default_exp_name'), $amount, $paid, $location_name, $lat, $lng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $is_return]); $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++; $validItemsCount++;
} }
} }
} }
if ($validItemsCount === 0) { if ($validItemsCount === 0) {
$stmt->execute([$holiday_id, 'activity', 'PF_TECHNICAL_POINT', 0, 1, $location_name, $lat, $lng, $target_order, '', null, null, $step_start, $step_end, 1, $is_return]); $stmt->execute([$holiday_id, 'activity', 'PF_TECHNICAL_POINT', 0, 1, $location_name, $lat, $lng, $target_order, '', null, null, $step_start, $step_end, 1, $step_type, 'local']);
}
if (isset($_POST['set_as_return']) && $_POST['set_as_return'] == '1') {
$pdo->prepare("UPDATE pf_holidays SET return_step_id = ? WHERE id = ?")->execute([$target_order, $holiday_id]);
} }
// 4. GESTION DES FAVORIS
if (isset($_POST['save_favorite']) && $_POST['save_favorite'] == '1') { if (isset($_POST['save_favorite']) && $_POST['save_favorite'] == '1') {
$stmtFav = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'holiday_favorites'"); $stmtFav = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'holiday_favorites'");
$favs = json_decode($stmtFav->fetchColumn() ?: '[]', true); $favs = json_decode($stmtFav->fetchColumn() ?: '[]', true);
@@ -100,7 +200,10 @@ if ($holiday_id > 0 && !empty($location_name)) {
} }
$pdo->commit(); $pdo->commit();
} catch (Exception $e) { $pdo->rollBack(); die($e->getMessage()); } } catch (Exception $e) {
$pdo->rollBack();
die($e->getMessage());
}
} }
header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id); header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id);
+43 -45
View File
@@ -1,10 +1,10 @@
<?php <?php
// modules/holidays/includes/api/save_holiday.php // modules/holidays/includes/api/save_holiday.php
// On remonte de 4 niveaux pour atteindre la racine (api -> includes -> holidays -> modules -> racine) // On remonte de 4 niveaux pour atteindre la racine
require dirname(__DIR__, 4) . '/includes/auth.php'; require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php'; require dirname(__DIR__, 4) . '/includes/db.php';
require_login(); // Si cette fonction nécessite une redirection, gère-la dans auth.php require_login();
if (isset($_POST['action_delete']) && $_POST['action_delete'] == '1') { if (isset($_POST['action_delete']) && $_POST['action_delete'] == '1') {
$stmt = $pdo->prepare("DELETE FROM pf_holidays WHERE id = ?"); $stmt = $pdo->prepare("DELETE FROM pf_holidays WHERE id = ?");
@@ -22,61 +22,60 @@ $status = $_POST['status'];
$food = !empty($_POST['budget_food']) ? $_POST['budget_food'] : 0; $food = !empty($_POST['budget_food']) ? $_POST['budget_food'] : 0;
$extra = !empty($_POST['budget_extra']) ? $_POST['budget_extra'] : 0; $extra = !empty($_POST['budget_extra']) ? $_POST['budget_extra'] : 0;
$notes = $_POST['notes']; $notes = $_POST['notes'];
// 🔥 NOUVEAU : On récupère le véhicule optionnel
$vehicle_id = !empty($_POST['vehicle_id']) ? (int)$_POST['vehicle_id'] : null;
try { try {
$pdo->beginTransaction(); $pdo->beginTransaction();
// 1. Détection d'un changement de nom pour refaire l'appel API
$image_url = null;
$old_title = null;
if ($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) { if ($id) {
// UPDATE // UPDATE
$sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=? WHERE id=?"; $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 = $pdo->prepare($sql);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $id]); $stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $image_url, $id]);
} else { } else {
// INSERT // INSERT
$sql = "INSERT INTO pf_holidays (title, period_hint, start_date, end_date, status, budget_food, budget_extra, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; $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 = $pdo->prepare($sql);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes]); $stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $image_url]);
$id = $pdo->lastInsertId(); $id = $pdo->lastInsertId();
} }
// GESTION INTELLIGENTE DES ITEMS
if (!empty($_POST['items']['name'])) {
$count = count($_POST['items']['name']);
// On prépare une requête qui met à jour si l'ID existe, sinon insère
$stmtItem = $pdo->prepare("
INSERT INTO pf_holidays_items (id, holiday_id, category, name, amount, is_paid, location_name)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
category = VALUES(category),
name = VALUES(name),
amount = VALUES(amount),
is_paid = VALUES(is_paid)
");
$keepIds = [];
for ($i = 0; $i < $count; $i++) {
$itemId = !empty($_POST['items']['id'][$i]) ? (int)$_POST['items']['id'][$i] : null;
$cat = $_POST['items']['cat'][$i] ?? 'activity';
$name = trim($_POST['items']['name'][$i] ?? '');
$amount = floatval($_POST['items']['amount'][$i] ?? 0);
$paid = (int)($_POST['items']['paid'][$i] ?? 0);
$loc = !empty($_POST['items']['location'][$i]) ? $_POST['items']['location'][$i] : null;
if (!empty($name)) {
$stmtItem->execute([$itemId, $id, $cat, $name, $amount, $paid, $loc]);
$keepIds[] = $itemId ?: $pdo->lastInsertId();
}
}
// Nettoyage : On supprime les items qui ont été retirés de la modale
// (Attention : uniquement ceux du voyage actuel qui ne sont plus dans la liste envoyée)
if (!empty($keepIds)) {
$placeholders = implode(',', array_fill(0, count($keepIds), '?'));
$sqlDel = "DELETE FROM pf_holidays_items WHERE holiday_id = ? AND id NOT IN ($placeholders)";
$pdo->prepare($sqlDel)->execute(array_merge([$id], $keepIds));
}
}
$pdo->commit(); $pdo->commit();
} catch (Exception $e) { } catch (Exception $e) {
@@ -84,6 +83,5 @@ try {
die("Erreur base de données : " . $e->getMessage()); die("Erreur base de données : " . $e->getMessage());
} }
// Redirection vers la page principale
header("Location: /holidays.php"); header("Location: /holidays.php");
exit; exit;
@@ -0,0 +1,46 @@
<?php
// modules/holidays/includes/api/upload_attachment.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;
}
$holiday_id = isset($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : 0;
$item_id = (isset($_POST['item_id']) && (int)$_POST['item_id'] > 0) ? (int)$_POST['item_id'] : null;
if ($holiday_id === 0 || empty($_FILES['file'])) {
echo json_encode(['success' => false, 'error' => 'Données manquantes ou fichier invalide']);
exit;
}
$file = $_FILES['file'];
// On range les fichiers dans un sous-dossier par voyage pour rester propre sur le NAS
$uploadDir = dirname(__DIR__, 4) . '/uploads/holidays/' . $holiday_id . '/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
// Sécurisation du nom de fichier (retrait des accents et caractères spéciaux)
$fileName = basename($file['name']);
$safeFileName = preg_replace("/[^a-zA-Z0-9.-]/", "_", $fileName);
$uniqueFileName = time() . '_' . $safeFileName;
$destination = $uploadDir . $uniqueFileName;
if (move_uploaded_file($file['tmp_name'], $destination)) {
// Enregistrement en base de données
$stmt = $pdo->prepare("INSERT INTO pf_holidays_attachments (holiday_id, item_id, file_name, file_path) VALUES (?, ?, ?, ?)");
$stmt->execute([$holiday_id, $item_id, $fileName, 'uploads/holidays/' . $holiday_id . '/' . $uniqueFileName]);
$attachmentId = $pdo->lastInsertId();
echo json_encode(['success' => true, 'id' => $attachmentId, 'file_name' => $fileName]);
} else {
echo json_encode(['success' => false, 'error' => 'Erreur lors de l\'écriture du fichier sur le NAS']);
}
+574 -188
View File
@@ -11,11 +11,18 @@ if ($id === 0) {
// Récupération des données du voyage // Récupération des données du voyage
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT h.*, SELECT h.*,
v.name as vehicle_name,
v.consumption as vehicle_consumption,
(COALESCE(h.budget_food, 0) + COALESCE(h.budget_extra, 0) + COALESCE((SELECT SUM(amount) FROM pf_holidays_items WHERE holiday_id = h.id), 0)) as total_cost, (COALESCE(h.budget_food, 0) + COALESCE(h.budget_extra, 0) + COALESCE((SELECT SUM(amount) FROM pf_holidays_items WHERE holiday_id = h.id), 0)) as total_cost,
(SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND is_paid = 1) as total_paid, (SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND is_paid = 1) as total_paid,
(SELECT COALESCE(SUM(amount), 0) FROM pf_savings WHERE holiday_id = h.id) as total_saved (SELECT COALESCE(SUM(amount), 0) FROM pf_savings WHERE holiday_id = h.id) as total_saved,
FROM pf_holidays h WHERE h.id = ? (SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND (expense_context = 'transit' OR (category = 'transport' AND (name LIKE '%Essence%' OR name LIKE '%Carburant%')))) as total_fuel,
(SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND category = 'transport' AND (name LIKE '%Péage%' OR name LIKE '%Peage%')) as total_tolls
FROM pf_holidays h
LEFT JOIN pf_vehicles v ON h.vehicle_id = v.id
WHERE h.id = ?
"); ");
$stmt->execute([$id]); $stmt->execute([$id]);
$holiday = $stmt->fetch(PDO::FETCH_ASSOC); $holiday = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -42,7 +49,7 @@ foreach ($items as $it) {
'sort_order' => $it['sort_order'], 'sort_order' => $it['sort_order'],
'step_start_date' => $it['step_start_date'], 'step_start_date' => $it['step_start_date'],
'step_end_date' => $it['step_end_date'], 'step_end_date' => $it['step_end_date'],
'is_return' => (int)$it['is_return'], 'step_type' => $it['step_type'] ?? 'stop',
'total_amount' => 0, 'total_amount' => 0,
'items' => [] 'items' => []
]; ];
@@ -59,8 +66,9 @@ $mapPoints = array_values($steps);
// Affichage de la date // Affichage de la date
$dateDisplay = htmlspecialchars($holiday['period_hint'] ?? ''); $dateDisplay = htmlspecialchars($holiday['period_hint'] ?? '');
if (empty($dateDisplay) && $holiday['start_date']) { if (empty($dateDisplay) && $holiday['start_date']) {
$dateDisplay = date('d/m/Y', strtotime($holiday['start_date'])); // 💡 Format Jour/Mois uniquement
if ($holiday['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($holiday['end_date'])); $dateDisplay = date('d/m', strtotime($holiday['start_date']));
if ($holiday['end_date']) $dateDisplay .= ' → ' . date('d/m', strtotime($holiday['end_date']));
} }
$cost = (float)$holiday['total_cost']; $cost = (float)$holiday['total_cost'];
@@ -69,166 +77,196 @@ $saved = (float)$holiday['total_saved'];
$leftToPay = max(0, $cost - $paid); $leftToPay = max(0, $cost - $paid);
$pctPaid = $cost > 0 ? min(100, ($paid / $cost) * 100) : 0; $pctPaid = $cost > 0 ? min(100, ($paid / $cost) * 100) : 0;
$pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0; $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
// Image de fond (API Pixabay ou fallback par défaut)
$bgImage = !empty($holiday['image_url']) ? htmlspecialchars($holiday['image_url']) : '/modules/home/assets/img/background.jpg';
?> ?>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
.mobile-only { display: none !important; }
@media (max-width: 768px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
</style>
<div class="pf-holidays-detail"> <div class="pf-holidays-detail">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; flex-wrap: wrap; gap: 15px;"> <!-- Scripts JSON invisibles -->
<div style="display: flex; flex-direction: column; gap: 12px;">
<a href="?tab=list" class="pf-btn btn-secondary pf-btn-small" style="width: fit-content; text-decoration: none;"><?= tr('btn_back') ?></a>
<div style="display: flex; align-items: center; gap: 15px;">
<h1 style="margin: 0; font-size: 1.8rem;"><?= htmlspecialchars($holiday['title']) ?></h1>
<span class="hol-badge-status"><?= tr('hdl_status_' . $holiday['status']) ?></span>
</div>
</div>
<div>
<script id="holidayDataJson" type="application/json"> <script id="holidayDataJson" type="application/json">
<?= json_encode(['main' => $holiday, 'items' => $generalItems], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?> <?= json_encode(['main' => $holiday, 'items' => $generalItems], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>
</script> </script>
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="editHoliday(JSON.parse(document.getElementById('holidayDataJson').textContent))">
<?= tr('btn_edit_bases') ?> <!-- 1. HERO HEADER IMMERSIF -->
<div class="hol-hero-banner">
<img src="<?= $bgImage ?>" class="hol-hero-bg-img" alt="">
<div class="hol-hero-overlay"></div>
<div class="hol-hero-content">
<div class="hol-hero-top">
<a href="?tab=list" class="hol-hero-back-link"> <?= tr('btn_back') ?></a>
<span class="hol-hero-status-badge"><?= tr('hdl_status_' . $holiday['status']) ?></span>
</div>
<h1 class="hol-hero-title"><?= htmlspecialchars($holiday['title']) ?></h1>
<div class="hol-hero-date-badge">
🗓️ <?= $dateDisplay ?>
</div>
<!-- LES PILLS (Indicateurs Route & Quotidien) -->
<div class="hol-hero-pills">
<!-- PILL ROUTE (Masqué par défaut, géré par OSRM) -->
<span id="block_total_distance" class="hol-pill-block hol-hidden">
<span class="hol-pill">
🛣️ <span id="global_total_distance">0</span> km (⏱️ <span id="global_total_duration">0h00</span>)
</span>
</span>
<!-- PILL PÉAGES -->
<span class="hol-pill">
💳 <?= number_format($holiday['total_tolls'], 0) ?>
</span>
<!-- PILL ESSENCE (Avec Edit + Oeil) -->
<span class="hol-pill">
<span id="global_fuel_cost"><?= number_format($holiday['total_fuel'], 0) ?></span> €
<span onclick="updateFuelPrice()" class="hol-pill-action" title="Modifier le prix de l'essence">(<span id="display_fuel_price">1.85</span>/L) ✏️</span>
<?php if ($holiday['total_fuel'] > 0 || $holiday['total_tolls'] > 0): ?>
<span onclick="openTransitModal()" class="hol-pill-action" title="Voir le détail des trajets">👁️</span>
<?php endif; ?>
</span>
<!-- PILL BURGER (Food) -->
<span class="hol-pill">
🍔 <?= number_format($holiday['budget_food'], 0) ?>
</span>
<!-- PILL CADEAU (Extras) -->
<span class="hol-pill">
🎁 <?= number_format($holiday['budget_extra'], 0) ?>
</span>
</div>
<!-- FINANCES GLOBALES -->
<div class="hol-hero-finances">
<div class="hol-finance-block">
<div class="hol-finance-label"><?= tr('hdl_total_budget') ?></div>
<div class="hol-finance-value"><?= number_format($cost, 0, ',', ' ') ?> €</div>
</div>
<div class="hol-finance-block hol-finance-right">
<div class="hol-finance-label"><?= tr('hdl_left_to_pay') ?></div>
<div class="hol-finance-value hol-finance-value-due"><?= number_format($leftToPay, 0, ',', ' ') ?> €</div>
</div>
</div>
<!-- Barre de progression -->
<div class="hol-progress-bar">
<div class="hol-progress-paid" style="width: <?= $pctPaid ?>%;"></div>
<div class="hol-progress-saved" style="width: <?= $pctSaved ?>%;"></div>
</div>
<div class="hol-hero-progress-labels">
<span class="hol-label-paid-text"> <?= tr('hdl_paid') ?> : <?= number_format($paid, 0, ',', ' ') ?> €</span>
<span class="hol-label-saved-text">💼 <?= tr('hdl_saved') ?> : <?= number_format($saved, 0, ',', ' ') ?> €</span>
</div>
</div>
</div>
<!-- 2. BARRE D'ACTIONS RAPIDES -->
<div class="hol-quick-actions-bar">
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="toggleTripMap()">
🗺️ Carte
</button>
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="openGlobalPlanningModal()">
📅 Planning
</button>
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="generateTravelBook()">
📖 PDF
</button>
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="editHoliday(JSON.parse(document.getElementById('holidayDataJson').textContent))">
⚙️ Modifier
</button> </button>
</div> </div>
</div>
<div class="hol-summary-card"> <!-- 3. LA CARTE (Masquée par défaut) -->
<div class="hol-summary-grid"> <div id="tripMapWrapper" class="hol-map-wrapper hol-hidden">
<div class="hol-summary-item"> <div class="hol-panel-header hol-map-panel-header">
<div class="hol-summary-label"><?= tr('hdl_label_period') ?></div> <h3 class="hol-map-title">Itinéraire</h3>
<div class="hol-summary-value"><?= $dateDisplay ?: tr('hdl_dates_to_define') ?></div>
</div>
<div class="hol-summary-item">
<div class="hol-summary-label"><?= tr('hdl_label_budget_food_extras') ?></div>
<div class="hol-summary-value">🍔 <?= number_format($holiday['budget_food'], 0) ?> € | 🎁 <?= number_format($holiday['budget_extra'], 0) ?> €</div>
</div>
<div class="hol-summary-item">
<div class="hol-summary-label"><?= tr('hdl_total_budget') ?></div>
<div class="hol-summary-value total"><?= number_format($cost, 0, ',', ' ') ?> €</div>
</div>
</div>
<div class="hol-progress-bar">
<div class="hol-progress-paid" style="width:<?= $pctPaid ?>%;" title="<?= tr('hdl_paid') ?>"></div>
<div class="hol-progress-saved" style="width:<?= $pctSaved ?>%;" title="<?= tr('hdl_saved') ?>"></div>
</div>
<div class="hol-progress-labels">
<span class="hol-label-paid"> <?= tr('hdl_paid') ?> : <?= number_format($paid, 0, ',', ' ') ?> €</span>
<span class="hol-label-saved">💼 <?= tr('hdl_saved') ?> : <?= number_format($saved, 0, ',', ' ') ?> €</span>
<span class="hol-label-left"> <?= tr('hdl_left_to_pay') ?> : <?= number_format($leftToPay, 0, ',', ' ') ?> €</span>
</div>
</div>
<div class="hol-layout-grid">
<div class="hol-panel">
<div class="hol-panel-header" style="flex-wrap: wrap; gap: 10px;">
<div class="hol-panel-header-group">
<h3 style="margin:0;"><?= tr('hdl_map_itinerary') ?></h3>
<div class="hol-map-legend"> <div class="hol-map-legend">
<span class="hol-legend-item" title="<?= tr('hdl_outbound') ?>"><span class="hol-legend-color aller"></span> <?= tr('hdl_outbound') ?></span> <span class="hol-legend-item"><span class="hol-legend-color aller"></span> Aller</span>
<span class="hol-legend-item" title="<?= tr('hdl_return') ?>"><span class="hol-legend-color retour"></span> <?= tr('hdl_return') ?></span> <span class="hol-legend-item"><span class="hol-legend-color retour"></span> Retour</span>
</div> </div>
</div> </div>
<button class="pf-btn pf-btn-small" onclick="openCheckpointModal('add')"><?= tr('hdl_btn_add_step') ?></button> <!-- IMPORTANT : On garde l'ID tripMap pour que Leaflet (OSRM) fonctionne ! -->
<div id="tripMap" class="hol-map-canvas"></div>
</div> </div>
<div id="tripMap" style="flex:1; width:100%; min-height: 400px; background:#f1f5f9; position: relative; z-index: 1;"></div> </div>
<div class="hol-panel"> <!-- 4. TIMELINE (Carnet de route Pleine Largeur) -->
<div class="hol-panel-header"> <div class="hol-panel hol-panel-timeline">
<h3 style="margin:0;"><?= tr('hdl_step_details') ?></h3> <div class="hol-panel-header hol-timeline-header">
<h3><?= tr('hdl_step_details') ?></h3>
<button class="pf-btn pf-btn-small" onclick="openCheckpointModal('add')"> <?= tr('hdl_btn_add_step') ?></button>
</div> </div>
<div class="hol-panel-body"> <div class="hol-panel-body">
<?php if (empty($steps)): ?> <?php if (empty($steps)): ?>
<p style="color:var(--text-muted); font-style:italic; text-align:center; margin-top:40px;"><?= tr('hdl_no_steps') ?></p> <p class="hol-empty-steps-text"><?= tr('hdl_no_steps') ?></p>
<?php else: ?> <?php else: ?>
<?php if (!empty($generalItems) || $holiday['budget_food'] > 0 || $holiday['budget_extra'] > 0): ?>
<div class="hol-checkpoint" style="border-left-color: #64748b; background: #f8fafc; margin-bottom: 20px;">
<div class="hol-cp-header" style="background: transparent;">
<div class="hol-cp-info-group">
<span style="font-size:1.4rem;">🌍</span>
<div class="hol-cp-title"><?= tr('hdl_general_costs') ?></div>
</div>
<?php
$generalTotal = $holiday['budget_food'] + $holiday['budget_extra'];
foreach ($generalItems as $gi) { $generalTotal += $gi['amount']; }
?>
<div class="hol-cp-actions-group">
<div class="hol-cp-total"><?= number_format($generalTotal, 2, ',', ' ') ?> €</div>
<button onclick='editHoliday(JSON.parse(document.getElementById("holidayDataJson").textContent))' class="btn-icon-small" title="<?= tr('btn_edit') ?>">⚙️</button>
</div>
</div>
<div class="hol-cp-body">
<?php if ($holiday['budget_food'] > 0): ?>
<div class="hol-expense-wrapper"><div class="hol-expense-main">
<span class="hol-expense-name" style="color:#64748b;">🍔 <?= tr('hdl_food_bev') ?></span>
<span><strong class="hol-expense-amount"><?= number_format($holiday['budget_food'], 2, ',', ' ') ?> €</strong><span class="status-pending">⏳</span></span>
</div></div>
<?php endif; ?>
<?php if ($holiday['budget_extra'] > 0): ?>
<div class="hol-expense-wrapper"><div class="hol-expense-main">
<span class="hol-expense-name" style="color:#64748b;">🎁 <?= tr('hdl_extras') ?></span>
<span><strong class="hol-expense-amount"><?= number_format($holiday['budget_extra'], 2, ',', ' ') ?> €</strong><span class="status-pending">⏳</span></span>
</div></div>
<?php endif; ?>
<?php foreach ($generalItems as $it):
$icon = match($it['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
?>
<div class="hol-expense-wrapper"><div class="hol-expense-main">
<span class="hol-expense-name"><?= $icon ?> <?= htmlspecialchars($it['name']) ?></span>
<span>
<strong class="hol-expense-amount"><?= number_format($it['amount'], 2, ',', ' ') ?> €</strong>
<span class="<?= $it['is_paid'] ? 'status-paid' : 'status-pending' ?>"><?= $it['is_paid'] ? '✓' : '⏳' ?></span>
</span>
</div></div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php foreach ($steps as $step): ?> <?php foreach ($steps as $step): ?>
<div id="step-card-<?= $step['sort_order'] ?>" class="hol-checkpoint hol-checkpoint-draggable" draggable="true" data-location="<?= htmlspecialchars($step['location_name']) ?>"> <div id="step-card-<?= $step['sort_order'] ?>" class="hol-checkpoint hol-checkpoint-draggable" draggable="true" data-location="<?= htmlspecialchars($step['location_name']) ?>">
<div class="hol-cp-header"> <div class="hol-step-header">
<div class="hol-cp-info-group">
<span class="desktop-only" style="color:#94a3b8; font-size:1.1rem; cursor:grab; user-select:none;"></span> <div class="hol-step-row-top">
<div class="mobile-only" style="flex-direction:column; gap:4px; margin-right:8px;"> <?php
<button type="button" onclick="moveStepMobile(this, -1)" class="btn-icon-small" style="width:24px!important; height:24px!important; font-size:0.6rem;"></button> $stepIcon = '📍';
<button type="button" onclick="moveStepMobile(this, 1)" class="btn-icon-small" style="width:24px!important; height:24px!important; font-size:0.6rem;"></button> if ($step['step_type'] === 'origin') {
$stepIcon = '🛫';
} elseif ($step['step_type'] === 'destination') {
$stepIcon = '🛬';
}
$isReturn = ($holiday['return_step_id'] !== null && $holiday['return_step_id'] == $step['sort_order']);
?>
<div class="hol-step-icon hol-step-icon-<?= htmlspecialchars($step['step_type']) ?>">
<?= $stepIcon ?>
</div> </div>
<div class="hol-cp-title" onclick="panMapTo(<?= $step['lat'] ?>, <?= $step['lng'] ?>)" title="<?= htmlspecialchars($step['location_name']) ?>">
📍 <?= htmlspecialchars($step['location_name']) ?> <div class="hol-step-title" onclick="panMapTo(<?= $step['lat'] ?>, <?= $step['lng'] ?>)" title="<?= htmlspecialchars($step['location_name']) ?>">
<?php if (!empty($step['is_return'])): ?> <?= htmlspecialchars($step['location_name']) ?>
<span style="background: #fff7ed; color: #ea580c; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: bold; margin-left: 5px; border: 1px solid #ffedd5; vertical-align: middle;">🏁 <?= tr('hdl_return') ?></span> </div>
<div class="hol-step-controls">
<span class="desktop-only hol-drag-handle"></span>
<div class="mobile-only hol-step-arrows">
<button type="button" onclick="moveStepMobile(this, -1)"></button>
<button type="button" onclick="moveStepMobile(this, 1)"></button>
</div>
</div>
</div>
<div class="hol-step-row-bottom">
<div class="hol-step-meta">
<?php if ($isReturn): ?>
<span class="hol-tag-return">🏁 Retour</span>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($step['step_start_date']) && !empty($step['step_end_date'])): ?> <?php if (!empty($step['step_start_date']) && !empty($step['step_end_date'])): ?>
<div class="hol-date-weather-wrapper"> <span class="hol-step-date">
<span class="hol-step-dates"> <?= date('d/m', strtotime($step['step_start_date'])) ?> ➔ <?= date('d/m', strtotime($step['step_end_date'])) ?>
<?= tr('hdl_from') ?> <?= date('d/m', strtotime($step['step_start_date'])) ?> <?= tr('hdl_to') ?> <?= date('d/m', strtotime($step['step_end_date'])) ?>
</span> </span>
<div class="hol-weather-info"></div> <div class="hol-weather-info"></div>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="hol-step-actions">
<span class="hol-step-price"><?= number_format($step['total_amount'], 2, ',', ' ') ?> €</span>
<button onclick="openGpsModal(<?= $step['lat'] ?>, <?= $step['lng'] ?>)"
class="btn-icon-small hol-btn-action-icon"
title="Y aller avec le GPS">
🧭
</button>
<button onclick="openDocsModal(<?= $step['sort_order'] ?>)" class="btn-icon-small hol-btn-action-icon" title="Documents & Billets">📎</button>
<button onclick='openCheckpointModal("edit", <?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small hol-btn-action-icon" title="<?= tr('btn_edit') ?>">✏️</button>
</div> </div>
<div class="hol-cp-actions-group">
<div class="hol-cp-total"><?= number_format($step['total_amount'], 2, ',', ' ') ?> €</div>
<button onclick='openPlanningModal(<?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="<?= tr('hdl_view_planning') ?>">📅</button>
<button onclick='openCheckpointModal("edit", <?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="<?= tr('btn_edit') ?>">✏️</button>
</div> </div>
</div> </div>
<div class="hol-cp-body"> <div class="hol-cp-body">
@@ -238,15 +276,32 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
if ($it['name'] === 'PF_TECHNICAL_POINT') continue; if ($it['name'] === 'PF_TECHNICAL_POINT') continue;
$visibleItemsCount++; $visibleItemsCount++;
$icon = match($it['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' }; $icon = match($it['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
// On vérifie si l'activité possède ses propres coordonnées GPS
$hasCustomGps = (!empty($it['lat']) && !empty($it['lng']) && ((float)$it['lat'] !== (float)$step['lat'] || (float)$it['lng'] !== (float)$step['lng']));
?> ?>
<div class="hol-expense-wrapper"> <div class="hol-expense-wrapper">
<div class="hol-expense-main"> <div class="hol-expense-main">
<span class="hol-expense-name"><?= $icon ?> <?= htmlspecialchars($it['name']) ?></span> <span class="hol-expense-name">
<?= $icon ?>
<?php if ($hasCustomGps): ?>
<span onclick="openGpsModal(<?= $it['lat'] ?>, <?= $it['lng'] ?>)" class="hol-custom-gps-link" title="Y aller (GPS)">
<?= htmlspecialchars($it['name']) ?>
</span>
<?php else: ?>
<?= htmlspecialchars($it['name']) ?>
<?php endif; ?>
</span>
<span> <span>
<strong class="hol-expense-amount"><?= number_format($it['amount'], 2, ',', ' ') ?> €</strong> <strong class="hol-expense-amount"><?= number_format($it['amount'], 2, ',', ' ') ?> €</strong>
<span class="<?= $it['is_paid'] ? 'status-paid' : 'status-pending' ?>" title="<?= $it['is_paid'] ? tr('hdl_paid') : tr('hdl_to_pay') ?>"><?= $it['is_paid'] ? '✓' : '⏳' ?></span> <span class="<?= $it['is_paid'] ? 'status-paid' : 'status-pending' ?>" title="<?= $it['is_paid'] ? tr('hdl_paid') : tr('hdl_to_pay') ?>"><?= $it['is_paid'] ? '✓' : '⏳' ?></span>
</span> </span>
</div> </div>
<?php if (!empty($it['notes'])): ?>
<div class="hol-expense-note">
<?= htmlspecialchars($it['notes']) ?>
</div>
<?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
@@ -259,21 +314,22 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
</div>
<?php if (!empty($holiday['notes'])): ?> <div class="pf-card hol-notes-card">
<div class="hol-summary-card" style="margin-top: 24px; padding: 25px; border-left: 5px solid #f59e0b;"> <h2 class="pf-card-title">📝 <?= tr('hdl_label_notes') ?></h2>
<h3 style="margin: 0 0 15px 0; font-size: 1.2rem; color: #0f172a; display: flex; align-items: center; gap: 8px;"> <div class="pf-card-body">
📝 <?= tr('hdl_label_notes') ?> <textarea id="holidayGlobalNotes" class="pf-input hol-notes-textarea" rows="6" placeholder="<?= tr('hdl_ph_notes') ?>"><?= htmlspecialchars($holiday['notes'] ?? '') ?></textarea>
</h3> <div class="hol-notes-actions">
<div style="font-size: 0.95rem; color: #334155; white-space: pre-wrap; line-height: 1.6;"> <button id="btnSaveHolidayNote" class="pf-btn" onclick="saveHolidayGlobalNote(<?= (int)$_GET['id'] ?>)">
<?= htmlspecialchars($holiday['notes']) ?> 💾 <?= tr('btn_save') ?>
</button>
</div>
</div> </div>
</div> </div>
<?php endif; ?>
</div> </div>
<div id="checkpointModal" class="pf-modal"> <div id="checkpointModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 600px;"> <div class="pf-modal-content" style="max-width: 600px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
@@ -281,6 +337,12 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
<button type="button" onclick="closeCheckpointModal()" class="pf-modal-close">&times;</button> <button type="button" onclick="closeCheckpointModal()" class="pf-modal-close">&times;</button>
</div> </div>
<!-- LES ONGLETS SONT ICI -->
<div style="display: flex; gap: 15px; border-bottom: 1px solid var(--border-light); margin-bottom: 20px;">
<button type="button" id="tabBtnInfo" onclick="switchCpTab('info')" style="background: none; border: none; font-weight: bold; font-size: 1rem; color: var(--primary); border-bottom: 2px solid var(--primary); padding-bottom: 8px; cursor: pointer; flex: 1;">📍 Infos de l'étape</button>
<button type="button" id="tabBtnProg" onclick="switchCpTab('prog')" style="background: none; border: none; font-weight: bold; font-size: 1rem; color: var(--text-muted); border-bottom: 2px solid transparent; padding-bottom: 8px; cursor: pointer; flex: 1;">🎟️ Programme & Frais</button>
</div>
<div id="cpSearchBlock" style="margin-bottom:20px;"> <div id="cpSearchBlock" style="margin-bottom:20px;">
<?php if (!empty($favorites)): ?> <?php if (!empty($favorites)): ?>
<div style="margin-bottom:15px; display:flex; gap:8px; flex-wrap:wrap;"> <div style="margin-bottom:15px; display:flex; gap:8px; flex-wrap:wrap;">
@@ -300,6 +362,7 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
<div id="searchResults" style="margin-top:10px; max-height:200px; overflow-y:auto;"></div> <div id="searchResults" style="margin-top:10px; max-height:200px; overflow-y:auto;"></div>
</div> </div>
<!-- FORMULAIRE (Le contenu apparaîtra ici) -->
<form action="/modules/holidays/includes/api/save_checkpoint.php" method="POST" id="formCheckpoint" style="display:none; border-top:1px solid #e2e8f0; padding-top:20px;"> <form action="/modules/holidays/includes/api/save_checkpoint.php" method="POST" id="formCheckpoint" style="display:none; border-top:1px solid #e2e8f0; padding-top:20px;">
<input type="hidden" name="holiday_id" value="<?= $id ?>"> <input type="hidden" name="holiday_id" value="<?= $id ?>">
<input type="hidden" name="old_location_name" id="cp_old_name"> <input type="hidden" name="old_location_name" id="cp_old_name">
@@ -307,35 +370,51 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
<input type="hidden" name="lng" id="cp_lng"> <input type="hidden" name="lng" id="cp_lng">
<input type="hidden" name="old_sort_order" id="cp_old_sort_order"> <input type="hidden" name="old_sort_order" id="cp_old_sort_order">
<!-- CONTENU DE L'ONGLET INFO -->
<div id="cpTabInfo">
<div class="form-group" style="margin-bottom:15px;"> <div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('hdl_label_step_name') ?></label> <label class="pf-label"><?= tr('hdl_label_step_name') ?></label>
<input type="text" name="location_name" id="cp_name" class="pf-input" style="font-weight:bold; color:var(--primary);" required> <input type="text" name="location_name" id="cp_name" class="pf-input" style="font-weight:bold; color:var(--primary);" required>
</div> </div>
<div class="form-group" style="margin-bottom:15px; background:#fff7ed; padding:10px; border-radius:8px; border:1px solid #ffedd5;">
<label class="pf-label" style="color:#ea580c;">📍 Type d'étape</label>
<select name="step_type" id="cp_step_type" class="pf-input" onchange="toggleStepDates(this.value)">
<option value="origin">DÉPART (Point de départ du voyage)</option>
<option value="stop">SÉJOUR (Étape classique avec arrivée et départ)</option>
<option value="destination">ARRIVÉE FINALE (Fin du voyage)</option>
</select>
</div>
<div style="display:flex; gap:15px; margin-bottom:15px; background:#f8fafc; padding:12px; border-radius:8px;"> <div style="display:flex; gap:15px; margin-bottom:15px; background:#f8fafc; padding:12px; border-radius:8px;">
<div class="form-group" style="flex:1;"> <div class="form-group" id="grp_start_date" style="flex:1;">
<label class="pf-label"><?= tr('hdl_label_arrival') ?></label> <label class="pf-label" id="lbl_start_date"><?= tr('hdl_label_arrival') ?></label>
<input type="date" name="step_start_date" id="cp_start_date" class="pf-input"> <input type="date" name="step_start_date" id="cp_start_date" class="pf-input">
</div> </div>
<div class="form-group" style="flex:1;"> <div class="form-group" id="grp_end_date" style="flex:1;">
<label class="pf-label"><?= tr('hdl_label_departure') ?></label> <label class="pf-label" id="lbl_end_date"><?= tr('hdl_label_departure') ?></label>
<input type="date" name="step_end_date" id="cp_end_date" class="pf-input"> <input type="date" name="step_end_date" id="cp_end_date" class="pf-input">
</div> </div>
</div> </div>
<div style="margin-bottom: 15px;"> <div style="margin-bottom: 20px; padding-top: 15px; border-top: 1px dashed #e2e8f0;">
<label style="display:flex; align-items:center; cursor:pointer; color:#ea580c; font-weight:600;"> <label style="display:flex; align-items:center; cursor:pointer; color:#ea580c; font-weight:600; font-size:0.9rem;">
<input type="checkbox" name="is_return" id="cp_is_return" value="1" style="margin-right:8px;"> <input type="checkbox" name="set_as_return" id="cp_set_as_return" value="1" style="margin-right:8px; width:16px; height:16px;">
🏁 <?= tr('hdl_return') ?> 🏁 Définir comme retour
</label> </label>
<p style="margin: 4px 0 0 24px; font-size: 0.75rem; color: #64748b;">La route sera tracée en orange à partir d'ici.</p>
</div>
</div> </div>
<!-- CONTENU DE L'ONGLET PROGRAMME & FRAIS -->
<div id="cpTabProg" class="hol-hidden" style="display: none;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<label class="pf-label" style="margin:0;"><?= tr('hdl_planned_expenses') ?></label> <label class="pf-label" style="margin:0;"><?= tr('hdl_planned_expenses') ?></label>
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="addCpExpenseLine()"><?= tr('hdl_btn_add_expense') ?></button> <button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="addCpExpenseLine()"><?= tr('hdl_btn_add_expense') ?></button>
</div> </div>
<div id="cpExpensesContainer" style="margin-bottom:15px;"></div> <div id="cpExpensesContainer" style="margin-bottom:15px;"></div>
</div>
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<label style="display:flex; align-items:center; cursor:pointer; font-size:0.85rem; color:#475569;"> <label style="display:flex; align-items:center; cursor:pointer; font-size:0.85rem; color:#475569;">
@@ -354,67 +433,374 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
</div> </div>
<div id="planningModal" class="pf-modal"> <div id="planningModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 550px;"> <div class="pf-modal-content hol-modal-planning">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;"> <div class="hol-modal-header">
<h3 id="planningModalTitle" style="margin:0; color:var(--primary);">📅 <?= tr('hdl_planning_title') ?></h3> <h3 id="planningModalTitle" class="hol-modal-title">📅 Planning Global</h3>
<button type="button" onclick="closePlanningModal()" class="pf-modal-close">&times;</button> <button type="button" onclick="closePlanningModal()" class="pf-modal-close">&times;</button>
</div> </div>
<div id="planningContainer" style="width: 100%;"></div>
<!-- Conteneur Injecté en JS -->
<div id="planningContainer" class="hol-planning-container"></div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" onclick="closePlanningModal()" class="pf-btn btn-secondary"><?= tr('btn_close') ?></button> <button type="button" onclick="closePlanningModal()" class="pf-btn btn-secondary"><?= tr('btn_close') ?></button>
</div> </div>
</div> </div>
</div> </div>
<div id="transitModal" class="pf-modal">
<div class="pf-modal-content hol-modal-sm">
<div class="hol-modal-header">
<h3 class="hol-modal-title">🛣️ Détail des trajets</h3>
<button type="button" onclick="closeTransitModal()" class="pf-modal-close">&times;</button>
</div>
<div id="transitDetailsContainer" class="hol-transit-container">
<p class="hol-empty-text">Calcul en cours...</p>
</div>
<div class="modal-footer">
<button type="button" onclick="closeTransitModal()" class="pf-btn btn-secondary"><?= tr('btn_close') ?></button>
</div>
</div>
</div>
<div id="gpsModal" class="pf-modal">
<div class="pf-modal-content hol-modal-gps">
<div class="hol-modal-header">
<h3 class="hol-modal-title">🧭 Y aller avec...</h3>
<button type="button" onclick="closeGpsModal()" class="pf-modal-close">×</button>
</div>
<div class="hol-gps-buttons">
<button onclick="launchGpsApp('waze')" class="pf-btn btn-secondary hol-gps-btn">
<img src="https://cdn.simpleicons.org/waze/05C8F0" alt="Waze" class="hol-gps-icon">
Waze
</button>
<button onclick="launchGpsApp('gmaps')" class="pf-btn btn-secondary hol-gps-btn">
<img src="https://cdn.simpleicons.org/googlemaps" alt="Google Maps" class="hol-gps-icon">
Google Maps
</button>
<button onclick="launchGpsApp('amaps')" class="pf-btn btn-secondary hol-gps-btn">
<img src="https://cdn.simpleicons.org/apple/000000" alt="Apple Maps" class="hol-gps-icon">
Apple Maps
</button>
</div>
</div>
</div>
<div id="docsModal" class="pf-modal">
<div class="pf-modal-content hol-modal-docs">
<div class="hol-modal-header">
<h3 class="hol-modal-title">📎 Porte-documents</h3>
<button type="button" onclick="closeDocsModal()" class="pf-modal-close">×</button>
</div>
<div class="hol-docs-dropzone">
<p class="hol-docs-hint">Ajoutez vos billets, réservations ou PDFs pour cette étape.</p>
<input type="file" id="docFileInput" class="hol-hidden" accept=".pdf,.png,.jpg,.jpeg" onchange="handleFileUpload(this)">
<button type="button" class="pf-btn btn-primary hol-docs-select-btn" onclick="document.getElementById('docFileInput').click()">
+ Sélectionner un fichier
</button>
<div id="uploadStatus" class="hol-upload-status"></div>
</div>
<div id="docsListContainer" class="hol-docs-list">
<p class="hol-empty-text">Aucun document pour cette étape.</p>
</div>
</div>
</div>
<?php include __DIR__ . '/modal.php'; ?> <?php include __DIR__ . '/modal.php'; ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<?php include __DIR__ . '/pdf_template.php'; ?>
<script> <script>
// --- 1. SÉCURISATION TRADUCTIONS ET VARIABLES --- // --- 1. SÉCURISATION TRADUCTIONS ET VARIABLES ---
window.MAP_POINTS = <?= json_encode($mapPoints ?? []) ?>; window.MAP_POINTS = <?= json_encode($mapPoints ?? []) ?>;
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR"; window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = { window.I18N = {
...(window.I18N || {}), ...(window.I18N || {}),
'hdl_js_search_loading': "<?= tr('hdl_js_search_loading') ?>", 'hdl_js_search_loading': <?= json_encode(tr('hdl_js_search_loading')) ?>,
'hdl_js_no_result': "<?= tr('hdl_js_no_result') ?>", 'hdl_js_no_result': <?= json_encode(tr('hdl_js_no_result')) ?>,
'hdl_js_confirm_del_trip': "<?= tr('hdl_js_confirm_del_trip') ?>", 'hdl_js_confirm_del_trip': <?= json_encode(tr('hdl_js_confirm_del_trip')) ?>,
'hdl_js_confirm_del_step': "<?= tr('hdl_js_confirm_del_step') ?>", 'hdl_js_confirm_del_step': <?= json_encode(tr('hdl_js_confirm_del_step')) ?>,
'hdl_js_step_label': "<?= tr('hdl_js_step_label') ?>", 'hdl_js_step_label': <?= json_encode(tr('hdl_js_step_label')) ?>,
'hdl_js_ph_expense_name': "<?= tr('hdl_js_ph_expense_name') ?>", 'hdl_js_ph_expense_name': <?= json_encode(tr('hdl_js_ph_expense_name')) ?>,
'hdl_js_delete_line': "<?= tr('btn_delete') ?>", 'hdl_js_delete_line': <?= json_encode(tr('btn_delete')) ?>,
'hdl_planning_title': "<?= tr('hdl_planning_title') ?>", 'hdl_planning_title': <?= json_encode(tr('hdl_planning_title')) ?>,
'hdl_to_place': "<?= tr('hdl_to_place') ?>", 'hdl_to_place': <?= json_encode(tr('hdl_to_place')) ?>,
'hdl_js_missing_dates_title': "<?= tr('hdl_js_missing_dates_title') ?>", 'hdl_js_missing_dates_title': <?= json_encode(tr('hdl_js_missing_dates_title')) ?>,
'hdl_js_missing_dates_msg': "<?= tr('hdl_js_missing_dates_msg') ?>", 'hdl_js_missing_dates_msg': <?= json_encode(tr('hdl_js_missing_dates_msg')) ?>,
'hdl_modal_title': "<?= tr('hdl_modal_title') ?>", 'hdl_modal_title': <?= json_encode(tr('hdl_modal_title')) ?>,
'hdl_js_edit_step': "<?= tr('hdl_js_edit_step') ?>", 'hdl_js_edit_step': <?= json_encode(tr('hdl_js_edit_step')) ?>,
'hdl_ph_notes': "<?= tr('hdl_ph_notes') ?>", 'hdl_ph_notes': <?= json_encode(tr('hdl_ph_notes')) ?>,
'hdl_btn_add_step': "<?= tr('hdl_btn_add_step') ?>", 'hdl_btn_add_step': <?= json_encode(tr('hdl_btn_add_step')) ?>,
'hdl_quick_edit_title': "<?= tr('hdl_quick_edit_title') ?>", 'hdl_quick_edit_title': <?= json_encode(tr('hdl_quick_edit_title')) ?>,
'hdl_paid': "<?= tr('hdl_paid') ?>", 'hdl_paid': <?= json_encode(tr('hdl_paid')) ?>,
'weather_sunny': <?= json_encode(tr('weather_sunny')) ?>,
'weather_cloudy': <?= json_encode(tr('weather_cloudy')) ?>,
'weather_rainy': <?= json_encode(tr('weather_rainy')) ?>,
'weather_snowy': <?= json_encode(tr('weather_snowy')) ?>,
'weather_forecast': <?= json_encode(tr('weather_forecast')) ?>,
'weather_historical': <?= json_encode(tr('weather_historical')) ?>
};
const savedFuelPrice = localStorage.getItem('holidays_fuel_price') || 1.85;
window.FUEL_PRICE = parseFloat(savedFuelPrice);
// --- NOUVELLES CLÉS MÉTÉO ICI --- const displayFuelEl = document.getElementById('display_fuel_price');
'weather_sunny': "<?= tr('weather_sunny') ?>", if (displayFuelEl) displayFuelEl.innerText = window.FUEL_PRICE.toFixed(2);
'weather_cloudy': "<?= tr('weather_cloudy') ?>",
'weather_rainy': "<?= tr('weather_rainy') ?>",
'weather_snowy': "<?= tr('weather_snowy') ?>",
'weather_forecast': "<?= tr('weather_forecast') ?>",
'weather_historical': "<?= tr('weather_historical') ?>"
};
// Fallback de sécurité pour s'assurer que les modales peuvent toujours se fermer window.VEHICLE_CONSUMPTION = <?= !empty($holiday['vehicle_consumption']) ? (float)$holiday['vehicle_consumption'] : 7 ?>;
window.closeCheckpointModal = window.closeCheckpointModal || function() { window.GLOBAL_RETURN_STEP_ID = <?= $holiday['return_step_id'] !== null ? $holiday['return_step_id'] : 'null' ?>;
// --- 2. SURCHARGE SÉCURISÉE DES FONCTIONS JS (APRÈS CHARGEMENT DE HOLIDAYS.JS) ---
document.addEventListener('DOMContentLoaded', function() {
// 1. Correction du conflit d'onglet
window.switchCpTab = function(tabId) {
const btnInfo = document.getElementById('tabBtnInfo');
const btnProg = document.getElementById('tabBtnProg');
const tabInfo = document.getElementById('cpTabInfo');
const tabProg = document.getElementById('cpTabProg');
if (tabId === 'info') {
if (btnInfo) { btnInfo.style.borderBottomColor = "var(--primary)"; btnInfo.style.color = "var(--primary)"; }
if (btnProg) { btnProg.style.borderBottomColor = "transparent"; btnProg.style.color = "var(--text-muted)"; }
if (tabInfo) { tabInfo.style.display = "block"; tabInfo.classList.remove("hol-hidden"); }
if (tabProg) { tabProg.style.display = "none"; tabProg.classList.add("hol-hidden"); }
} else {
if (btnProg) { btnProg.style.borderBottomColor = "var(--primary)"; btnProg.style.color = "var(--primary)"; }
if (btnInfo) { btnInfo.style.borderBottomColor = "transparent"; btnInfo.style.color = "var(--text-muted)"; }
if (tabProg) { tabProg.style.display = "block"; tabProg.classList.remove("hol-hidden"); }
if (tabInfo) { tabInfo.style.display = "none"; tabInfo.classList.add("hol-hidden"); }
}
};
// 2. Correction modale : ajout de l'injection lat/lng aux items
window.openCheckpointModal = function(mode, data = null) {
const searchBlock = document.getElementById("cpSearchBlock");
const formBlock = document.getElementById("formCheckpoint");
const container = document.getElementById("cpExpensesContainer");
const btnDel = document.getElementById("btnDeleteCp");
const modalTitle = document.getElementById("cpModalTitle");
if(container) container.innerHTML = "";
if (document.getElementById("cp_start_date")) document.getElementById("cp_start_date").value = "";
if (document.getElementById("cp_end_date")) document.getElementById("cp_end_date").value = "";
if (document.getElementById("searchPlaceInput")) document.getElementById("searchPlaceInput").value = "";
if (document.getElementById("searchResults")) document.getElementById("searchResults").innerHTML = "";
window.switchCpTab('info');
if (mode === "add") {
if (modalTitle) modalTitle.innerText = "📍 " + (window.I18N['hdl_btn_add_step'] || "Ajouter une étape");
if (searchBlock) searchBlock.style.display = "block";
if (formBlock) formBlock.style.display = "block";
if (btnDel) btnDel.style.display = "none";
if (document.getElementById("cp_old_sort_order")) document.getElementById("cp_old_sort_order").value = "";
if (document.getElementById("cp_name")) document.getElementById("cp_name").value = "";
if (document.getElementById("cp_step_type")) {
document.getElementById("cp_step_type").value = "stop";
if (typeof toggleStepDates === 'function') toggleStepDates("stop");
}
if (document.getElementById("cp_set_as_return")) document.getElementById("cp_set_as_return").checked = false;
if (typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
} else if (mode === "edit" && data) {
if (modalTitle) modalTitle.innerText = "✏️ " + (window.I18N['hdl_js_edit_step'] || "Modifier l'étape");
if (searchBlock) searchBlock.style.display = "none";
if (formBlock) formBlock.style.display = "block";
if (btnDel) btnDel.style.display = "block";
if (document.getElementById("cp_lat")) document.getElementById("cp_lat").value = data.lat;
if (document.getElementById("cp_lng")) document.getElementById("cp_lng").value = data.lng;
if (document.getElementById("cp_old_sort_order")) document.getElementById("cp_old_sort_order").value = data.sort_order;
if (document.getElementById("cp_name")) document.getElementById("cp_name").value = data.location_name;
if (document.getElementById("cp_start_date")) document.getElementById("cp_start_date").value = data.step_start_date || "";
if (document.getElementById("cp_end_date")) document.getElementById("cp_end_date").value = data.step_end_date || "";
if (document.getElementById("cp_step_type")) {
const type = data.step_type || "stop";
document.getElementById("cp_step_type").value = type;
if (typeof toggleStepDates === 'function') toggleStepDates(type);
}
if (document.getElementById("cp_set_as_return")) {
document.getElementById("cp_set_as_return").checked = (window.GLOBAL_RETURN_STEP_ID == data.sort_order);
}
// Génération avec transmission de la latitude et longitude de chaque dépense
if (data.items && data.items.length > 0) {
let visibleCount = 0;
data.items.forEach((it) => {
if (it.name !== "PF_TECHNICAL_POINT") {
if (typeof window.addCpExpenseLine === 'function') {
window.addCpExpenseLine(
it.category, it.name, it.amount, it.is_paid, it.notes || "",
it.id || "", it.item_date || "", it.item_time || "", it.duration || 1,
it.expense_context || "local", it.lat || "", it.lng || ""
);
}
visibleCount++;
}
});
if (visibleCount === 0 && typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
} else {
if (typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
}
}
try {
if (typeof cpDateRangePicker !== 'undefined' && cpDateRangePicker && typeof cpDateRangePicker.destroy === 'function') {
cpDateRangePicker.destroy();
}
} catch (e) {}
const modal = document.getElementById("checkpointModal");
if (modal) {
modal.style.display = "flex";
document.body.classList.add("no-scroll");
}
};
// 3. LA FONCTION AVEC LA CLASSE CSS PARFAITE ET LE BOUTON 📍
window.addCpExpenseLine = function(category = "accommodation", name = "", amount = "", isPaid = 0, notes = "", itemId = "", itemDate = "", itemTime = "", itemDur = 1, expenseContext = "local", itemLat = "", itemLng = "") {
const container = document.getElementById("cpExpensesContainer");
if (!container) return;
const div = document.createElement("div");
div.className = "hol-form-row"; // Force l'utilisation du CSS
const isChecked = isPaid == 1 ? "checked" : "";
// Indicateur visuel (bouton vert) si on a déjà des coordonnées pour cette activité
const pinBtnStyle = (itemLat && itemLng) ? "background:var(--success);color:white;border-color:var(--success);" : "";
div.innerHTML = `
<div class="hol-form-inner">
<select name="items[cat][]" class="pf-input hol-form-select">
<option value="accommodation" ${category === "accommodation" ? "selected" : ""}>🏨</option>
<option value="transport" ${category === "transport" ? "selected" : ""}>🚗</option>
<option value="activity" ${category === "activity" ? "selected" : ""}>🎫</option>
</select>
<input type="hidden" name="items[context][]" value="${expenseContext}">
<input type="text" name="items[name][]" class="pf-input hol-form-text" placeholder="${window.I18N['hdl_js_ph_expense_name'] || 'Nom de la dépense'}" value="${name}" required>
<button type="button" class="btn-icon-action" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border-light);border-radius:6px; margin:0; flex-shrink:0; ${pinBtnStyle}" onclick="window.toggleItemSearch(this)" title="Adresse précise">📍</button>
<input type="number" step="0.01" name="items[amount][]" class="pf-input hol-form-number" placeholder="0.00" value="${amount}" required>
<label class="hol-form-paid-label" title="${window.I18N['hdl_paid'] || 'Payé'}">
<input type="checkbox" ${isChecked} onchange="this.nextElementSibling.value = this.checked ? 1 : 0" style="width:16px;height:16px;margin:0;accent-color:var(--primary);">
<input type="hidden" name="items[paid][]" value="${isPaid}">
<span class="hol-form-paid-text">Payé</span>
</label>
<button type="button" onclick="this.parentElement.parentElement.remove()" title="${window.I18N['hdl_js_delete_line'] || 'Supprimer'}" class="btn-icon-action delete btn-remove-expense" style="margin: 0;">🗑️</button>
</div>
<div class="hol-item-search-box" style="display:none; padding: 10px; background: var(--bg-subtle); border-radius: 8px; margin-top: 6px; width: 100%;">
<div style="display:flex; gap:10px;">
<input type="text" class="pf-input item-search-input" placeholder="Chercher une adresse..." onkeypress="if(event.key === 'Enter') { window.searchItemPlace(this); return false; }">
<button type="button" class="pf-btn btn-secondary" onclick="window.searchItemPlace(this)">🔍</button>
</div>
<div class="item-search-results" style="margin-top:10px; max-height:150px; overflow-y:auto;"></div>
</div>
<div class="hol-form-subrow" style="display: flex; padding-left: 58px; margin-top: 6px;">
<input type="text" name="items[notes][]" class="pf-input hol-form-notes-input" placeholder="${window.I18N['hdl_ph_notes'] || 'Notes...'}" value="${notes}" style="font-size: 0.8rem; padding: 6px 8px; border-style: dashed; color: var(--text-muted); width: 100%; background: transparent; margin: 0;">
</div>
<input type="hidden" name="items[id][]" value="${itemId}">
<input type="hidden" name="items[date][]" value="${itemDate}">
<input type="hidden" name="items[time][]" value="${itemTime}">
<input type="hidden" name="items[duration][]" value="${itemDur}">
<input type="hidden" name="items[lat][]" value="${itemLat}">
<input type="hidden" name="items[lng][]" value="${itemLng}">
`;
container.appendChild(div);
};
// 4. RÉINTÉGRATION DES FONCTIONS DE GÉOCODAGE DANS LE MÊME SCOPE
window.toggleItemSearch = function(btn) {
const searchBox = btn.closest(".hol-form-row").querySelector(".hol-item-search-box");
searchBox.style.display = searchBox.style.display === "none" ? "block" : "none";
};
window.searchItemPlace = async function(btn) {
const container = btn.closest(".hol-item-search-box");
const input = container.querySelector(".item-search-input");
const resultsDiv = container.querySelector(".item-search-results");
const q = input.value.trim();
if (q.length < 3) return;
resultsDiv.innerHTML = `<span style="color:#64748b; font-size:0.85rem;">Recherche en cours...</span>`;
try {
const data = await pachaFetch("/modules/holidays/includes/api/geocode.php?limit=5&q=" + encodeURIComponent(q), { method: 'GET' });
resultsDiv.innerHTML = "";
if (data.error || !data.results || data.results.length === 0) {
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">Aucun résultat</span>`;
return;
}
data.results.forEach((place) => {
const b = document.createElement("button");
b.type = "button";
b.className = "pf-btn btn-secondary";
b.style.textAlign = "left";
b.style.padding = "8px";
b.style.marginBottom = "4px";
b.style.width = "100%";
b.innerText = "📍 " + place.display_name;
b.onclick = () => window.selectItemPlace(container, place.lat, place.lng, place.display_name);
resultsDiv.appendChild(b);
});
} catch (err) {
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">Erreur de connexion</span>`;
}
};
window.selectItemPlace = function(container, lat, lng, displayName) {
const row = container.closest(".hol-form-row");
row.querySelector('input[name="items[lat][]"]').value = lat;
row.querySelector('input[name="items[lng][]"]').value = lng;
// Si le champ note est vide, on l'utilise pour y placer le nom complet de l'adresse
const notesInput = row.querySelector('input[name="items[notes][]"]');
if (notesInput.value === "") {
notesInput.value = displayName;
}
container.style.display = "none";
// Le bouton devient vert pour confirmer la présence de coordonnées
const pinBtn = row.querySelector('.btn-icon-action[title="Adresse précise"]');
if (pinBtn) {
pinBtn.style.background = "var(--success)";
pinBtn.style.color = "white";
pinBtn.style.borderColor = "var(--success)";
}
};
window.closeCheckpointModal = function() {
const modal = document.getElementById('checkpointModal'); const modal = document.getElementById('checkpointModal');
if(modal) modal.style.display = 'none'; if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll'); document.body.classList.remove('no-scroll');
}; };
window.closePlanningModal = window.closePlanningModal || function() { window.closePlanningModal = function() {
const modal = document.getElementById('planningModal'); const modal = document.getElementById('planningModal');
if(modal) modal.style.display = 'none'; if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll'); document.body.classList.remove('no-scroll');
}; };
});
</script> </script>
<script src="/modules/holidays/holidays.js"></script>
+5 -3
View File
@@ -122,9 +122,10 @@ window.closeHolidayModal = window.closeHolidayModal || function() {
if(modal) modal.style.display = 'none'; if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll'); document.body.classList.remove('no-scroll');
}; };
</script> </script>
<script src="/modules/holidays/holidays.js"></script>
<?php <?php
function renderHolidayCard($h, $pdo) { function renderHolidayCard($h, $pdo) {
@@ -136,8 +137,9 @@ function renderHolidayCard($h, $pdo) {
$dateDisplay = htmlspecialchars($h['period_hint'] ?? ''); $dateDisplay = htmlspecialchars($h['period_hint'] ?? '');
if (empty($dateDisplay) && $h['start_date']) { if (empty($dateDisplay) && $h['start_date']) {
$dateDisplay = date('d/m/Y', strtotime($h['start_date'])); // 💡 Format Jour/Mois uniquement
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($h['end_date'])); $dateDisplay = date('d/m', strtotime($h['start_date']));
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m', strtotime($h['end_date']));
} }
$statusClass = match($h['status']) { $statusClass = match($h['status']) {
+19 -44
View File
@@ -1,5 +1,5 @@
<div id="holidayModal" class="pf-modal"> <div id="holidayModal" class="pf-modal">
<div class="pf-modal-content hol-modal-content"> <div class="pf-modal-content hol-modal-content" style="max-width: 500px;">
<h3 id="modalTitle" class="pf-modal-title"><?= tr('hdl_modal_title') ?></h3> <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"> <form action="/modules/holidays/includes/api/save_holiday.php" method="POST" id="holidayForm">
@@ -24,68 +24,43 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<div> <div class="hol-flex-1">
<label class="pf-label"><?= tr('hdl_label_period') ?></label> <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') ?>"> <input type="text" name="period_hint" id="inp_period" class="pf-input" placeholder="<?= tr('hdl_ph_period') ?>">
</div> </div>
<div class="hol-date-range-group"> <!-- DATES AVEC FLATPICKR -->
<div class="hol-flex-1"> <div class="hol-flex-2">
<label class="pf-label"><?= tr('hdl_label_from') ?></label> <label class="pf-label">📅 Dates du voyage</label>
<input type="date" name="start_date" id="inp_start" class="pf-input"> <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;">
</div> <!-- Vraies données pour save_holiday.php -->
<div class="hol-flex-1"> <input type="hidden" name="start_date" id="inp_start">
<label class="pf-label"><?= tr('hdl_label_to') ?></label> <input type="hidden" name="end_date" id="inp_end">
<input type="date" name="end_date" id="inp_end" class="pf-input">
</div>
</div> </div>
</div> </div>
<hr class="hol-divider"> <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>
<div class="hol-columns-wrapper"> <select name="vehicle_id" id="inp_vehicle_id" class="pf-input">
<div class="hol-col"> <option value="">-- Aucun / Autre transport --</option>
<div class="hol-col-header"> <?php foreach($garageVehicles as $v): ?>
<h4 class="hol-cat-transport">🚗 <?= tr('hdl_cat_transport') ?></h4> <option value="<?= $v['id'] ?>"><?= htmlspecialchars($v['name']) ?></option>
<button type="button" class="btn-add-item" onclick="addItem('transport')" title="<?= tr('hdl_add_transport') ?>"></button> <?php endforeach; ?>
</div> </select>
<div id="list_transport" class="dynamic-list"></div>
</div>
<div class="hol-col">
<div class="hol-col-header">
<h4 class="hol-cat-accommodation">🏨 <?= tr('hdl_cat_accommodation') ?></h4>
<button type="button" class="btn-add-item" onclick="addItem('accommodation')" title="<?= tr('hdl_add_accommodation') ?>"></button>
</div>
<div id="list_accommodation" class="dynamic-list"></div>
</div>
<div class="hol-col">
<div class="hol-col-header">
<h4 class="hol-cat-activity">🎫 <?= tr('hdl_cat_activity') ?></h4>
<button type="button" class="btn-add-item" onclick="addItem('activity')" title="<?= tr('hdl_add_activity') ?>"></button>
</div>
<div id="list_activity" class="dynamic-list"></div>
</div>
</div> </div>
<hr class="hol-divider"> <hr class="hol-divider">
<div class="form-row"> <div class="form-row">
<div> <div class="hol-flex-1">
<label class="pf-label">🍔 <?= tr('hdl_label_budget_food') ?></label> <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"> <input type="number" step="0.01" name="budget_food" id="inp_food" class="pf-input" placeholder="0.00">
</div> </div>
<div> <div class="hol-flex-1">
<label class="pf-label">🎁 <?= tr('hdl_label_budget_extras') ?></label> <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"> <input type="number" step="0.01" name="budget_extra" id="inp_extra" class="pf-input" placeholder="0.00">
</div> </div>
</div> </div>
<div class="form-group">
<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"> <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="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> <button type="button" onclick="closeHolidayModal()" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
+140
View File
@@ -0,0 +1,140 @@
<?php
// Calcul du budget global pour la page de synthèse
$totalGeneral = $holiday['budget_food'] + $holiday['budget_extra'];
foreach ($generalItems as $gi) {
$totalGeneral += $gi['amount'];
}
$totalSteps = 0;
foreach ($steps as $step) {
$totalSteps += $step['total_amount'];
}
$grandTotal = $totalGeneral + $totalSteps;
?>
<div style="display: none;">
<div id="travelBookTemplate" style="padding: 40px; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; color: #1e293b; background: white; width: 100%; max-width: 800px; margin: 0 auto;">
<div style="text-align: center; padding-top: 50px; margin-bottom: 60px;">
<div style="font-size: 4rem; margin-bottom: 20px;">🗺️</div>
<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', strtotime($holiday['start_date'])) ?> au <?= date('d/m', strtotime($holiday['end_date'])) ?>
<?php else: ?>
Dates à définir
<?php endif; ?>
</h2>
</div>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin-bottom: 40px; page-break-inside: avoid;">
<h3 style="margin-top: 0; color: #0f172a; border-bottom: 2px solid #cbd5e1; padding-bottom: 10px;">💰 Budget Prévisionnel : <?= number_format($grandTotal, 2, ',', ' ') ?> €</h3>
<table style="width: 100%; border-collapse: collapse; margin-top: 15px;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #e2e8f0; color: #475569;">Frais Généraux (Vols, Locations globales...)</td>
<td style="padding: 8px 0; border-bottom: 1px solid #e2e8f0; text-align: right; font-weight: bold;"><?= number_format($totalGeneral, 2, ',', ' ') ?> €</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #475569;">Étapes (Hébergements, Activités...)</td>
<td style="padding: 8px 0; text-align: right; font-weight: bold;"><?= number_format($totalSteps, 2, ',', ' ') ?> €</td>
</tr>
</table>
</div>
<?php if (!empty($generalItems) || $holiday['budget_food'] > 0 || $holiday['budget_extra'] > 0): ?>
<div style="margin-bottom: 40px; page-break-inside: avoid;">
<h3 style="color: #0f172a; border-bottom: 2px solid #0ea5e9; padding-bottom: 5px;">🌍 Réservations & Frais Généraux</h3>
<ul style="list-style-type: none; padding-left: 0; margin-top: 15px;">
<?php if ($holiday['budget_food'] > 0): ?>
<li style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">🍔 Budget Nourriture : <strong><?= number_format($holiday['budget_food'], 2, ',', ' ') ?> €</strong></li>
<?php endif; ?>
<?php if ($holiday['budget_extra'] > 0): ?>
<li style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">🎁 Extras & Souvenirs : <strong><?= number_format($holiday['budget_extra'], 2, ',', ' ') ?> €</strong></li>
<?php endif; ?>
<?php foreach ($generalItems as $gi):
$icon = match($gi['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
?>
<li style="padding: 8px 0; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between;">
<span><?= $icon ?> <?= htmlspecialchars($gi['name']) ?></span>
<span><strong><?= number_format($gi['amount'], 2, ',', ' ') ?> €</strong> <span style="font-size: 0.85em; color: #64748b;"><?= $gi['is_paid'] ? '(Payé ✓)' : '(À payer ⏳)' ?></span></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div style="page-break-before: always;"></div>
<h2 style="color: #0f172a; font-size: 2rem; border-bottom: 3px solid #0f172a; padding-bottom: 10px; margin-bottom: 30px;">📍 Itinéraire Détaillé</h2>
<?php foreach ($steps as $index => $step): ?>
<?php if ($index > 0): ?>
<div style="text-align: center; padding: 10px 0; color: #94a3b8; page-break-inside: avoid;">
<div style="border-left: 2px dashed #cbd5e1; height: 25px; margin: 0 auto; width: 2px;"></div>
<div style="margin: 5px 0; font-size: 0.9rem;">🚗 <em>En route vers l'étape suivante...</em></div>
<div style="border-left: 2px dashed #cbd5e1; height: 25px; margin: 0 auto; width: 2px;"></div>
</div>
<?php endif; ?>
<div style="background: #ffffff; border: 1px solid #e2e8f0; border-left: 6px solid #0ea5e9; border-radius: 8px; padding: 20px; margin-bottom: 0px; page-break-inside: avoid;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
<div>
<h3 style="margin: 0; font-size: 1.4rem; color: #0f172a;"><?= htmlspecialchars($step['location_name']) ?></h3>
<?php if (!empty($step['step_start_date']) && !empty($step['step_end_date'])): ?>
<div style="color: #0ea5e9; margin-top: 5px; font-weight: bold; font-size: 0.95rem;">
📅 Du <?= date('d/m', strtotime($step['step_start_date'])) ?> au <?= date('d/m', strtotime($step['step_end_date'])) ?>
</div>
<?php endif; ?>
</div>
<div style="text-align: right;">
<span style="background: #f1f5f9; padding: 5px 10px; border-radius: 6px; font-weight: bold; color: #0f172a;">
<?= number_format($step['total_amount'], 2, ',', ' ') ?>
</span>
</div>
</div>
<?php $checkpoints = isset($step['items']) ? $step['items'] : []; ?>
<?php if (!empty($checkpoints)): ?>
<div style="margin-top: 15px; border-top: 1px solid #f8fafc; padding-top: 15px;">
<h4 style="margin-top: 0; margin-bottom: 10px; color: #475569; font-size: 1rem;">📋 Planning & Activités :</h4>
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
<?php foreach ($checkpoints as $cp):
$cpIcon = match($cp['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
// Sécurité sur la date d'activité (item_date ou date)
$cpDate = !empty($cp['item_date']) ? $cp['item_date'] : (!empty($cp['date']) ? $cp['date'] : null);
?>
<tr>
<td style="padding: 6px 0; border-bottom: 1px solid #f8fafc; color: #64748b; width: 80px;">
<?= $cpDate ? date('d/m', strtotime($cpDate)) : '---' ?>
</td>
<td style="padding: 6px 0; border-bottom: 1px solid #f8fafc; color: #334155;">
<?= $cpIcon ?> <?= htmlspecialchars($cp['name']) ?>
</td>
<td style="padding: 6px 0; border-bottom: 1px solid #f8fafc; text-align: right; font-weight: bold; color: #0f172a;">
<?= number_format($cp['amount'], 2, ',', ' ') ?>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<div style="page-break-before: always;"></div>
<h2 style="color: #0f172a; font-size: 2rem; border-bottom: 3px solid #0f172a; padding-bottom: 10px; margin-bottom: 30px;">📝 Notes & Informations utiles</h2>
<p style="color: #64748b; margin-bottom: 40px; font-style: italic;">Espace réservé pour vos numéros d'urgence, codes de cadenas, adresses locales...</p>
<?php for ($i = 0; $i < 15; $i++): ?>
<div style="border-bottom: 1px dotted #cbd5e1; height: 35px; width: 100%;"></div>
<?php endfor; ?>
<div style="text-align: center; margin-top: 60px; font-size: 0.85rem; color: #94a3b8;">
Carnet de route généré automatiquement par <strong>HouseHub</strong>. Bon voyage ! ✈️
</div>
</div>
</div>
+16 -4
View File
@@ -15,6 +15,7 @@ require_once dirname(__DIR__, 2) . '/includes/db.php';
// ── Auto-create tables ──────────────────────────────────────────────────────── // ── Auto-create tables ────────────────────────────────────────────────────────
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists ( $pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL DEFAULT 'Ma liste', id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
color VARCHAR(20) DEFAULT NULL, list_type VARCHAR(50) DEFAULT NULL,
position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
@@ -48,6 +49,8 @@ $pdo->exec("CREATE TABLE IF NOT EXISTS pf_item_category_rules (
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN category_id INT DEFAULT NULL AFTER list_id"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN category_id INT DEFAULT NULL AFTER list_id"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_lists ADD COLUMN color VARCHAR(20) DEFAULT NULL AFTER name"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_lists ADD COLUMN list_type VARCHAR(50) DEFAULT NULL AFTER color"); } catch (\Exception $e) {}
// ── Seed categories if empty ────────────────────────────────────────────────── // ── Seed categories if empty ──────────────────────────────────────────────────
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) { if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) {
@@ -313,23 +316,32 @@ if ($action === 'set_category' && $method === 'POST') {
if ($action === 'lists') { if ($action === 'lists') {
if ($method === 'GET') { if ($method === 'GET') {
$firstId = liste_ensure_default($pdo); $firstId = liste_ensure_default($pdo);
$rows = $pdo->query("SELECT id, name, position FROM pf_lists ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC); $rows = $pdo->query("SELECT id, name, color, list_type, position FROM pf_lists ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['lists' => $rows, 'default_id' => $firstId]); echo json_encode(['lists' => $rows, 'default_id' => $firstId]);
exit; exit;
} }
if ($method === 'POST') { if ($method === 'POST') {
$name = mb_substr(trim($body['name'] ?? ''), 0, 100); $name = mb_substr(trim($body['name'] ?? ''), 0, 100);
$color = mb_substr(trim($body['color'] ?? ''), 0, 20) ?: null;
$list_type = mb_substr(trim($body['list_type'] ?? ''), 0, 50) ?: null;
if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; } if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
$pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn(); $pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn();
$pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?,?)")->execute([$name, $pos]); $pdo->prepare("INSERT INTO pf_lists (name, color, list_type, position) VALUES (?,?,?,?)")->execute([$name, $color, $list_type, $pos]);
echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'position' => $pos]); echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'color' => $color, 'list_type' => $list_type, 'position' => $pos]);
exit; exit;
} }
if ($method === 'PUT') { if ($method === 'PUT') {
$id = (int)($_GET['id'] ?? 0); $id = (int)($_GET['id'] ?? 0);
$name = mb_substr(trim($body['name'] ?? ''), 0, 100); $name = mb_substr(trim($body['name'] ?? ''), 0, 100);
$color = array_key_exists('color', $body) ? (mb_substr(trim($body['color'] ?? ''), 0, 20) ?: null) : false;
$list_type = array_key_exists('list_type', $body) ? (mb_substr(trim($body['list_type'] ?? ''), 0, 50) ?: null) : false;
if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; } if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; }
$pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([$name, $id]); $sets = ['name=?'];
$vals = [$name];
if ($color !== false) { $sets[] = 'color=?'; $vals[] = $color; }
if ($list_type !== false) { $sets[] = 'list_type=?'; $vals[] = $list_type; }
$vals[] = $id;
$pdo->prepare("UPDATE pf_lists SET " . implode(', ', $sets) . " WHERE id=?")->execute($vals);
echo json_encode(['ok' => true]); exit; echo json_encode(['ok' => true]); exit;
} }
if ($method === 'DELETE') { if ($method === 'DELETE') {
+136
View File
@@ -310,6 +310,135 @@
} }
.liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; } .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 ──────────────────────────────────────────────── */ /* ── Category section headers ──────────────────────────────────────────────── */
.liste-cat-section-header { .liste-cat-section-header {
@@ -429,6 +558,13 @@
[data-theme='dark'] .btn-tool:hover { background: #2a2a3e; } [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 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-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 { border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; } [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-section-count { background: #2a2a3e; color: #8892b0; }
+168 -65
View File
@@ -11,6 +11,35 @@ const state = {
catMap: {}, catMap: {},
}; };
// ── Constants ──────────────────────────────────────────────────────────────────
const LIST_COLORS = [
{ id: 'none', hex: null },
{ id: 'blue', hex: '#3b82f6' },
{ id: 'red', hex: '#ef4444' },
{ id: 'amber', hex: '#f59e0b' },
{ id: 'purple', hex: '#8b5cf6' },
{ id: 'pink', hex: '#ec4899' },
{ id: 'emerald', hex: '#10b981' },
{ id: 'orange', hex: '#f97316' },
{ id: 'cyan', hex: '#06b6d4' },
{ id: 'indigo', hex: '#6366f1' },
{ id: 'teal', hex: '#14b8a6' },
{ id: 'gray', hex: '#6b7280' },
];
const LIST_TYPES = [
{ value: '', label: '— Aucun —', emoji: '' },
{ value: 'courses', label: 'Courses', emoji: '🛒' },
{ value: 'todo', label: 'To-do', emoji: '✅' },
{ value: 'voyage', label: 'Voyage', emoji: '✈️' },
{ value: 'travail', label: 'Travail', emoji: '💼' },
{ value: 'maison', label: 'Maison', emoji: '🏠' },
{ value: 'sante', label: 'Santé', emoji: '💊' },
{ value: 'loisirs', label: 'Loisirs', emoji: '🎮' },
{ value: 'autre', label: 'Autre', emoji: '📋' },
];
// ── Utilities ────────────────────────────────────────────────────────────────── // ── Utilities ──────────────────────────────────────────────────────────────────
function esc(s) { function esc(s) {
@@ -42,6 +71,116 @@ async function api(action, method = 'GET', body = null, params = {}) {
return r.json(); return r.json();
} }
// ── List modal ─────────────────────────────────────────────────────────────────
let activeModal = null;
function openListModal(listId = null) {
if (activeModal) { activeModal.remove(); activeModal = null; }
const list = listId ? state.lists.find(l => l.id === listId) : null;
const isNew = !listId;
const backdrop = document.createElement('div');
backdrop.className = 'liste-modal-backdrop';
backdrop.innerHTML = `
<div class="liste-modal" role="dialog" aria-modal="true">
<div class="liste-modal-header">
<h3>${isNew ? 'Nouvelle liste' : 'Modifier la liste'}</h3>
<button class="liste-modal-close" aria-label="Fermer">×</button>
</div>
<div class="liste-modal-body">
<div class="liste-form-group">
<label class="liste-form-label">Nom</label>
<input class="liste-modal-name" type="text" maxlength="100"
value="${esc(list?.name ?? '')}" placeholder="Ma liste…" autocomplete="off">
</div>
<div class="liste-form-group">
<label class="liste-form-label">Couleur</label>
<div class="liste-color-picker">
${LIST_COLORS.map(c => {
const isActive = (c.hex === null && !list?.color) || (list?.color === c.hex);
const cls = 'liste-color-swatch' + (c.hex === null ? ' liste-color-swatch-none' : '') + (isActive ? ' active' : '');
const style = c.hex ? `style="--swatch:${c.hex}"` : '';
return `<button class="${cls}" data-color="${c.hex ?? ''}" ${style} title="${c.id}"></button>`;
}).join('')}
</div>
</div>
<div class="liste-form-group">
<label class="liste-form-label">Type de liste</label>
<select class="liste-modal-type">
${LIST_TYPES.map(t =>
`<option value="${esc(t.value)}" ${list?.list_type === t.value ? 'selected' : ''}>${t.emoji ? t.emoji + ' ' : ''}${esc(t.label)}</option>`
).join('')}
</select>
</div>
</div>
<div class="liste-modal-footer">
${!isNew && state.lists.length > 1
? `<button class="btn-tool btn-tool-danger liste-modal-delete">Supprimer</button>`
: ''}
<button class="btn-tool liste-modal-cancel">Annuler</button>
<button class="btn-tool btn-tool-primary liste-modal-save">Enregistrer</button>
</div>
</div>`;
document.body.appendChild(backdrop);
activeModal = backdrop;
const nameInput = backdrop.querySelector('.liste-modal-name');
nameInput.focus();
nameInput.setSelectionRange(nameInput.value.length, nameInput.value.length);
// Color swatches
backdrop.querySelectorAll('.liste-color-swatch').forEach(btn => {
btn.addEventListener('click', () => {
backdrop.querySelectorAll('.liste-color-swatch').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
const close = () => { backdrop.remove(); activeModal = null; };
backdrop.querySelector('.liste-modal-close').addEventListener('click', close);
backdrop.querySelector('.liste-modal-cancel').addEventListener('click', close);
backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
backdrop.querySelector('.liste-modal-delete')?.addEventListener('click', () => { close(); deleteList(listId); });
backdrop.querySelector('.liste-modal-save').addEventListener('click', () => saveListModal(backdrop, listId));
nameInput.addEventListener('keydown', e => {
if (e.key === 'Enter') saveListModal(backdrop, listId);
if (e.key === 'Escape') close();
});
}
async function saveListModal(backdrop, listId) {
const name = backdrop.querySelector('.liste-modal-name').value.trim();
if (!name) { backdrop.querySelector('.liste-modal-name').focus(); return; }
const activeSwatch = backdrop.querySelector('.liste-color-swatch.active');
const color = activeSwatch?.dataset.color || null;
const list_type = backdrop.querySelector('.liste-modal-type').value || null;
if (listId) {
const r = await api('lists', 'PUT', { name, color, list_type }, { id: listId });
if (r.ok) {
const list = state.lists.find(l => l.id === listId);
if (list) { list.name = name; list.color = color; list.list_type = list_type; }
backdrop.remove(); activeModal = null;
renderTabs();
toast('Liste mise à jour');
}
} else {
const r = await api('lists', 'POST', { name, color, list_type });
if (r.id) {
state.lists.push({ id: r.id, name, color: r.color, list_type: r.list_type, position: r.position });
state.currentListId = r.id;
backdrop.remove(); activeModal = null;
await loadItems();
renderTabs();
toast('Liste créée : ' + name);
}
}
}
// ── Categories ───────────────────────────────────────────────────────────────── // ── Categories ─────────────────────────────────────────────────────────────────
async function loadCategories() { async function loadCategories() {
@@ -104,49 +243,54 @@ function closeCategoryPicker() {
// ── Tabs ─────────────────────────────────────────────────────────────────────── // ── Tabs ───────────────────────────────────────────────────────────────────────
function typeEmoji(list_type) {
return LIST_TYPES.find(t => t.value === list_type)?.emoji || '';
}
function renderTabs() { function renderTabs() {
const container = document.getElementById('liste-tabs'); const container = document.getElementById('liste-tabs');
if (!container) return; if (!container) return;
container.innerHTML = ''; container.innerHTML = '';
state.lists.forEach(list => { state.lists.forEach(list => {
const isActive = list.id === state.currentListId;
const tab = document.createElement('div'); const tab = document.createElement('div');
tab.className = 'liste-tab' + (list.id === state.currentListId ? ' active' : ''); tab.className = 'liste-tab' + (isActive ? ' active' : '');
tab.dataset.id = list.id; tab.dataset.id = list.id;
if (list.id === state.currentListId) { // Apply custom color to entire tab
if (list.color) {
if (isActive) {
tab.style.cssText = `background:${list.color};border-color:${list.color};color:#fff`;
} else {
tab.style.cssText = `border-color:${list.color};color:${list.color}`;
}
}
const emoji = typeEmoji(list.list_type);
const prefix = emoji ? `<span class="liste-tab-type">${emoji}</span>` : '';
if (isActive) {
tab.innerHTML = ` tab.innerHTML = `
${prefix}
<span class="liste-tab-name">${esc(list.name)}</span> <span class="liste-tab-name">${esc(list.name)}</span>
<button class="liste-tab-btn liste-tab-rename" title="${esc(T.rename_list)}" data-id="${list.id}"></button> <button class="liste-tab-btn liste-tab-edit" title="Modifier" data-id="${list.id}"></button>
${state.lists.length > 1 ? `<button class="liste-tab-btn liste-tab-delete" title="Supprimer" data-id="${list.id}">×</button>` : ''}
`; `;
} else { } else {
tab.innerHTML = `<span class="liste-tab-name">${esc(list.name)}</span>`; tab.innerHTML = `${prefix}<span class="liste-tab-name">${esc(list.name)}</span>`;
tab.addEventListener('click', () => switchList(list.id)); tab.addEventListener('click', () => switchList(list.id));
} }
container.appendChild(tab); container.appendChild(tab);
}); });
container.querySelectorAll('.liste-tab-rename').forEach(btn => { container.querySelectorAll('.liste-tab-edit').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); }); btn.addEventListener('click', e => { e.stopPropagation(); openListModal(parseInt(btn.dataset.id)); });
});
container.querySelectorAll('.liste-tab-delete').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); });
}); });
} }
function startRename(listId) { // ── List management ─────────────────────────────────────────────────────────────
const list = state.lists.find(l => l.id === listId);
if (!list) return; document.getElementById('btn-add-list')?.addEventListener('click', () => openListModal(null));
const name = prompt(T.new_name || 'Nouveau nom :', list.name);
if (name === null || name.trim() === '') return;
api('lists', 'PUT', { name: name.trim() }, { id: listId }).then(r => {
if (r.ok) {
list.name = name.trim();
renderTabs();
toast(T.list_renamed || 'Liste renommée');
}
});
}
async function deleteList(listId) { async function deleteList(listId) {
if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return; if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return;
@@ -161,41 +305,6 @@ async function deleteList(listId) {
toast(T.list_deleted || 'Liste supprimée'); toast(T.list_deleted || 'Liste supprimée');
} }
// ── List management ─────────────────────────────────────────────────────────────
document.getElementById('btn-add-list')?.addEventListener('click', () => {
document.getElementById('liste-new-form')?.classList.remove('hidden');
document.getElementById('btn-add-list')?.classList.add('hidden');
document.getElementById('new-list-name')?.focus();
});
document.getElementById('btn-cancel-new-list')?.addEventListener('click', cancelNewList);
document.getElementById('btn-confirm-new-list')?.addEventListener('click', confirmNewList);
document.getElementById('new-list-name')?.addEventListener('keydown', e => {
if (e.key === 'Enter') confirmNewList();
if (e.key === 'Escape') cancelNewList();
});
function cancelNewList() {
document.getElementById('liste-new-form')?.classList.add('hidden');
document.getElementById('btn-add-list')?.classList.remove('hidden');
if (document.getElementById('new-list-name')) document.getElementById('new-list-name').value = '';
}
async function confirmNewList() {
const input = document.getElementById('new-list-name');
const name = input?.value.trim();
if (!name) return;
const r = await api('lists', 'POST', { name });
if (r.id) {
state.lists.push({ id: r.id, name: r.name, position: r.position });
state.currentListId = r.id;
cancelNewList();
await loadItems();
renderTabs();
toast((T.list_created || 'Liste créée') + ' : ' + r.name);
}
}
// ── Switch list ───────────────────────────────────────────────────────────────── // ── Switch list ─────────────────────────────────────────────────────────────────
async function switchList(listId) { async function switchList(listId) {
@@ -207,10 +316,7 @@ async function switchList(listId) {
// ── Items ─────────────────────────────────────────────────────────────────────── // ── Items ───────────────────────────────────────────────────────────────────────
async function loadItems() { async function loadItems() {
if (!state.currentListId) { if (!state.currentListId) { renderItems(); return; }
renderItems();
return;
}
const r = await api('items', 'GET', null, { list_id: state.currentListId }); const r = await api('items', 'GET', null, { list_id: state.currentListId });
state.items = r.items || []; state.items = r.items || [];
renderItems(); renderItems();
@@ -236,15 +342,12 @@ function renderItems() {
let html = ''; let html = '';
if (hasCategories && state.categories.length) { if (hasCategories && state.categories.length) {
// Group pending items by category
const groups = new Map(); const groups = new Map();
pending.forEach(item => { pending.forEach(item => {
const key = item.category_id ?? 0; const key = item.category_id ?? 0;
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item); groups.get(key).push(item);
}); });
// Render in category order, uncategorized last
const catIds = state.categories.map(c => parseInt(c.id)); const catIds = state.categories.map(c => parseInt(c.id));
const orderedKeys = catIds.filter(id => groups.has(id)); const orderedKeys = catIds.filter(id => groups.has(id));
if (groups.has(0)) orderedKeys.push(0); if (groups.has(0)) orderedKeys.push(0);
+1 -1
View File
@@ -153,7 +153,7 @@ function renderAttachmentsView(atts,noteId){
<div class="memo-att-title">🖼 Images</div> <div class="memo-att-title">🖼 Images</div>
<div class="att-img-grid">${images.map(a=>` <div class="att-img-grid">${images.map(a=>`
<div class="att-img-item" onclick="openLightbox('${API}?action=file&id=${a.id}')"> <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> <button class="att-del" onclick="event.stopPropagation();deleteAttachment(${a.id},${noteId})" title="Supprimer"></button>
</div>`).join('')}</div></div>`; </div>`).join('')}</div></div>`;
} }
+233
View File
@@ -0,0 +1,233 @@
<?php
// modules/voyage/api.php
// Toll cost estimator API for HouseHub
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
$action = $_GET['action'] ?? '';
if ($action !== 'estimate') {
echo json_encode(['ok' => false, 'error' => 'Action inconnue']);
exit;
}
// --- Read POST body ---
$body = file_get_contents('php://input');
$params = json_decode($body, true);
if (!$params || empty($params['stops']) || count($params['stops']) < 2) {
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides (stops requis, min 2)']);
exit;
}
$stops = $params['stops'];
$fuelL100 = (float)($params['fuel_l100'] ?? 7);
$fuelPrice= (float)($params['fuel_price'] ?? 1.85);
// --- Load pre-indexed PHP database ---
$tollsDataPath = __DIR__ . '/data/tolls.data.php';
$gpsPath = __DIR__ . '/data/toll_gps.json';
if (!file_exists($tollsDataPath)) {
echo json_encode(['ok' => false, 'error' => 'Base de péages introuvable']);
exit;
}
// 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) ?? [];
}
// --- Helper: great-circle distance (km) ---
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
$R = 6371.0;
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
return $R * 2 * atan2(sqrt($a), sqrt(1 - $a));
}
// --- Find plazas near a coordinate (within $maxKm) ---
// Returns array of [plaza_name => distance_km]
function plazasNear(array $gpsData, float $lat, float $lng, float $maxKm): array {
$result = [];
foreach ($gpsData as $name => $info) {
$d = haversine($lat, $lng, $info['lat'], $info['lng']);
if ($d <= $maxKm) {
$result[$name] = $d;
}
}
asort($result);
return $result;
}
// --- Look up toll price for (op, entry, exit) with fallback ---
function lookupToll(array $tollIndex, string $op, string $entry, string $exit): ?float {
// Direct lookup
if (isset($tollIndex[$op][$entry][$exit])) {
return $tollIndex[$op][$entry][$exit];
}
// Reversed
if (isset($tollIndex[$op][$exit][$entry])) {
return $tollIndex[$op][$exit][$entry];
}
return null;
}
// --- OSRM route distance ---
function getRouteDistanceKm(float $lat1, float $lng1, float $lat2, float $lng2): ?float {
$url = sprintf(
'https://router.project-osrm.org/route/v1/driving/%f,%f;%f,%f?overview=false',
$lng1, $lat1, $lng2, $lat2
);
$ctx = stream_context_create([
'http' => [
'timeout' => 10,
'header' => "User-Agent: HouseHub/1.0\r\n"
]
]);
$resp = @file_get_contents($url, false, $ctx);
if ($resp === false) return null;
$data = json_decode($resp, true);
if (!empty($data['routes'][0]['distance'])) {
return (float)$data['routes'][0]['distance'] / 1000.0;
}
return null;
}
// --- Main: process each consecutive stop pair ---
$segments = [];
$totalKm = 0.0;
$totalToll = 0.0;
for ($i = 0; $i < count($stops) - 1; $i++) {
$orig = $stops[$i];
$dest = $stops[$i + 1];
$oLat = (float)$orig['lat'];
$oLng = (float)$orig['lng'];
$dLat = (float)$dest['lat'];
$dLng = (float)$dest['lng'];
$fromName = $orig['name'] ?? "Étape " . ($i + 1);
$toName = $dest['name'] ?? "Étape " . ($i + 2);
// Distance via OSRM
$distKm = getRouteDistanceKm($oLat, $oLng, $dLat, $dLng);
if ($distKm === null) {
// Fallback: straight-line distance * 1.25
$distKm = haversine($oLat, $oLng, $dLat, $dLng) * 1.25;
}
$totalKm += $distKm;
// --- Find toll ---
$segToll = 0.0;
$entryPlaza = null;
$exitPlaza = null;
$tollNote = null;
if (!empty($gpsData)) {
// Find plazas near origin (80km) and destination (80km)
$nearOrig = plazasNear($gpsData, $oLat, $oLng, 80.0);
$nearDest = plazasNear($gpsData, $dLat, $dLng, 80.0);
$bestToll = null;
$bestDistSum = PHP_FLOAT_MAX;
$bestEntry = null;
$bestExit = null;
$bestOp = null;
// Try each operator
foreach ($tollIndex as $op => $opEntries) {
// Find candidate entry plazas (near origin, present in this operator)
$candidateEntries = [];
foreach ($nearOrig as $pName => $dist) {
if (isset($opEntries[$pName]) || isset(array_flip(array_keys($opEntries))[$pName])) {
// Plaza exists as entry in this operator
if (isset($opEntries[$pName])) {
$candidateEntries[$pName] = $dist;
}
}
// Also check if it's an exit plaza (reversed lookup later)
// Include any plaza from this operator that is within range
}
// Build set of all plazas for this operator
$opPlazas = [];
foreach ($opEntries as $entry => $exits) {
$opPlazas[$entry] = true;
foreach ($exits as $exit => $price) {
$opPlazas[$exit] = true;
}
}
// Filter nearOrig and nearDest to only plazas in this operator
$opNearOrig = array_intersect_key($nearOrig, $opPlazas);
$opNearDest = array_intersect_key($nearDest, $opPlazas);
if (empty($opNearOrig) || empty($opNearDest)) continue;
// Try best 5 entry and best 5 exit candidates
$topEntries = array_slice($opNearOrig, 0, 5, true);
$topExits = array_slice($opNearDest, 0, 5, true);
foreach ($topEntries as $ePlaza => $eDist) {
foreach ($topExits as $xPlaza => $xDist) {
if ($ePlaza === $xPlaza) continue;
$price = lookupToll($tollIndex, $op, $ePlaza, $xPlaza);
if ($price !== null) {
$distSum = $eDist + $xDist;
if ($distSum < $bestDistSum) {
$bestDistSum = $distSum;
$bestToll = $price;
$bestEntry = $ePlaza;
$bestExit = $xPlaza;
$bestOp = $op;
}
}
}
}
}
if ($bestToll !== null) {
$segToll = $bestToll;
$entryPlaza = $bestEntry;
$exitPlaza = $bestExit;
} else {
$tollNote = 'péages non estimés pour ce trajet';
}
} else {
$tollNote = 'base GPS des péages non disponible';
}
$totalToll += $segToll;
$seg = [
'from' => $fromName,
'to' => $toName,
'distance_km' => round($distKm, 1),
'toll' => round($segToll, 2),
];
if ($entryPlaza) $seg['entry_plaza'] = $entryPlaza;
if ($exitPlaza) $seg['exit_plaza'] = $exitPlaza;
if ($bestOp) $seg['op'] = $bestOp;
if ($tollNote) $seg['note'] = $tollNote;
$segments[] = $seg;
}
$fuelCost = round(($totalKm / 100.0) * $fuelL100 * $fuelPrice, 2);
$grandTotal = round($totalToll + $fuelCost, 2);
echo json_encode([
'ok' => true,
'segments' => $segments,
'total_km' => round($totalKm, 1),
'total_toll' => round($totalToll, 2),
'fuel_cost' => $fuelCost,
'grand_total' => $grandTotal,
], JSON_UNESCAPED_UNICODE);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+57 -6
View File
@@ -38,6 +38,47 @@ function createFamilyDb(PDO $meta, string $db_host, string $db_user, string $db_
return $db_name; return $db_name;
} }
function generateCalendarWeeks(PDO $familyPdo) {
$monthsFr = [1=>'Janvier', 2=>'Février', 3=>'Mars', 4=>'Avril', 5=>'Mai', 6=>'Juin', 7=>'Juillet', 8=>'Août', 9=>'Septembre', 10=>'Octobre', 11=>'Novembre', 12=>'Décembre'];
$sql = "REPLACE INTO pf_calendar_weeks
(year, week_iso_year, week_iso_number, week_label, month, month_name, week_start_date, mon_date, tue_date, wed_date, thu_date, fri_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $familyPdo->prepare($sql);
// On commence de l'année précédente jusqu'à +10 ans
$currentYear = (int)date('Y');
$startDate = new DateTime(($currentYear - 1) . '-08-28'); // On s'assure de couvrir la rentrée scolaire précédente
$endDate = new DateTime(($currentYear + 10) . '-12-31');
while ($startDate <= $endDate) {
$mon = clone $startDate;
$tue = clone $startDate; $tue->modify('+1 day');
$wed = clone $startDate; $wed->modify('+2 days');
$thu = clone $startDate; $thu->modify('+3 days');
$fri = clone $startDate; $fri->modify('+4 days');
$month = (int)$mon->format('n');
$stmt->execute([
(int)$mon->format('Y'),
(int)$mon->format('o'),
(int)$mon->format('W'),
"Semaine " . (int)$mon->format('W'),
$month,
$monthsFr[$month],
$mon->format('Y-m-d'),
$mon->format('Y-m-d'),
$tue->format('Y-m-d'),
$wed->format('Y-m-d'),
$thu->format('Y-m-d'),
$fri->format('Y-m-d')
]);
$startDate->modify('+1 week');
}
}
// ─── Traitement du formulaire ───────────────────────────────────────────────── // ─── Traitement du formulaire ─────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -90,13 +131,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
"INSERT INTO users (username, password_hash, display_name, family_id) VALUES (?, ?, ?, ?)" "INSERT INTO users (username, password_hash, display_name, family_id) VALUES (?, ?, ?, ?)"
)->execute([$username, $hash, $display_name, $family['id']]); )->execute([$username, $hash, $display_name, $family['id']]);
$user_id = (int)$meta_pdo->lastInsertId();
// 🔥 INJECTION ICI : Ajouter la personne à la base de la famille (Couleur orangée pour différencier)
$tenant_db = $family['db_name'];
$tenantPdo = new PDO("mysql:host=$db_host;dbname=$tenant_db;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$stmtPerson = $tenantPdo->prepare("INSERT INTO pf_people (name, user_id, role, color, is_active) VALUES (?, ?, 'parent', '#f59e0b', 1)");
$stmtPerson->execute([$display_name, $user_id]);
$_SESSION['user'] = [ $_SESSION['user'] = [
'id' => (int)$meta_pdo->lastInsertId(), 'id' => $user_id,
'username' => $username, 'username' => $username,
'display_name' => $display_name, 'display_name' => $display_name,
'family_id' => (int)$family['id'], 'family_id' => (int)$family['id'],
]; ];
$_SESSION['family_db'] = $family['db_name']; $_SESSION['family_db'] = $tenant_db;
session_regenerate_id(true); session_regenerate_id(true);
header('Location: /index.php'); header('Location: /index.php');
exit; exit;
@@ -129,6 +178,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
)->execute([$username, $hash, $display_name, $family_id]); )->execute([$username, $hash, $display_name, $family_id]);
$user_id = (int)$meta_pdo->lastInsertId(); $user_id = (int)$meta_pdo->lastInsertId();
$tenantPdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$stmtPerson = $tenantPdo->prepare("INSERT INTO pf_people (name, user_id, role, color, is_active) VALUES (?, ?, 'parent', '#0891b2', 1)");
$stmtPerson->execute([$display_name, $user_id]);
generateCalendarWeeks($tenantPdo);
$meta_pdo->commit(); $meta_pdo->commit();
$_SESSION['user'] = [ $_SESSION['user'] = [
@@ -169,7 +224,6 @@ require __DIR__ . '/header.php';
<div class="pf-login-error"><?= htmlspecialchars($error) ?></div> <div class="pf-login-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<!-- Onglets -->
<div style="display:flex;gap:8px;margin-bottom:24px"> <div style="display:flex;gap:8px;margin-bottom:24px">
<button id="tab-create" onclick="switchTab('create')" <button id="tab-create" onclick="switchTab('create')"
class="pf-btn" style="flex:1">Nouvel espace</button> class="pf-btn" style="flex:1">Nouvel espace</button>
@@ -177,7 +231,6 @@ require __DIR__ . '/header.php';
class="pf-btn btn-secondary" style="flex:1">Rejoindre</button> class="pf-btn btn-secondary" style="flex:1">Rejoindre</button>
</div> </div>
<!-- Formulaire commun -->
<form method="post" action="/register.php" id="reg-form"> <form method="post" action="/register.php" id="reg-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" id="reg-action" value="create"> <input type="hidden" name="action" id="reg-action" value="create">
@@ -208,7 +261,6 @@ require __DIR__ . '/header.php';
placeholder="••••••" autocomplete="new-password"> placeholder="••••••" autocomplete="new-password">
</div> </div>
<!-- Champs spécifiques : Nouvel espace -->
<div id="section-create"> <div id="section-create">
<div class="pf-form-group"> <div class="pf-form-group">
<label class="pf-label">Nom de la famille / foyer</label> <label class="pf-label">Nom de la famille / foyer</label>
@@ -218,7 +270,6 @@ require __DIR__ . '/header.php';
</div> </div>
</div> </div>
<!-- Champs spécifiques : Rejoindre -->
<div id="section-join" style="display:none"> <div id="section-join" style="display:none">
<div class="pf-form-group"> <div class="pf-form-group">
<label class="pf-label">Code d'invitation</label> <label class="pf-label">Code d'invitation</label>
+260 -23
View File
@@ -1,10 +1,18 @@
-- HouseHub — Schéma MySQL -- HouseHub — Schéma MySQL (Modèle pour une base famille)
-- Créer la base : CREATE DATABASE househub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4; SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0; SET FOREIGN_KEY_CHECKS = 0;
-- ─── Utilisateurs ───────────────────────────────────────────────────────────── -- ─── Configuration du foyer ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT '',
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '', 'C');
-- ─── Utilisateurs (Legacy) ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_users ( CREATE TABLE IF NOT EXISTS pf_users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE, username VARCHAR(100) NOT NULL UNIQUE,
@@ -13,9 +21,13 @@ CREATE TABLE IF NOT EXISTS pf_users (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Personnes ──────────────────────────────────────────────────────────────── -- ─── Personnes ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_people ( CREATE TABLE `pf_people` (
id INT AUTO_INCREMENT PRIMARY KEY, `id` int(11) NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL `name` varchar(100) NOT NULL,
`user_id` int(11) DEFAULT NULL,
`role` varchar(50) DEFAULT NULL,
`color` varchar(7) DEFAULT '#0891b2',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Calendrier familial ────────────────────────────────────────────────────── -- ─── Calendrier familial ──────────────────────────────────────────────────────
@@ -91,7 +103,7 @@ CREATE TABLE IF NOT EXISTS pf_budget_items (
CREATE TABLE IF NOT EXISTS pf_expenses ( CREATE TABLE IF NOT EXISTS pf_expenses (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
date_exp DATE NOT NULL, date_exp DATE NOT NULL,
gestion_month VARCHAR(7) NOT NULL, gestion_month VARCHAR(10) NOT NULL,
category VARCHAR(100), category VARCHAR(100),
label VARCHAR(255), label VARCHAR(255),
amount DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0,
@@ -103,24 +115,26 @@ CREATE TABLE IF NOT EXISTS pf_expenses (
CREATE TABLE IF NOT EXISTS pf_alloc_categories ( CREATE TABLE IF NOT EXISTS pf_alloc_categories (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
target DECIMAL(10,2) DEFAULT 0, target DECIMAL(10,2) DEFAULT 0.00,
transfer_dest VARCHAR(50) DEFAULT NULL,
holiday_id INT DEFAULT NULL, holiday_id INT DEFAULT NULL,
sort_order INT DEFAULT 0 sort_order INT DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_alloc_values ( CREATE TABLE IF NOT EXISTS pf_alloc_values (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
month_date VARCHAR(7) NOT NULL, month_date VARCHAR(10) NOT NULL,
cat_id INT NOT NULL, cat_id INT NOT NULL,
amount_alex DECIMAL(10,2) DEFAULT 0, person_id INT NOT NULL,
amount_laia DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0.00,
UNIQUE KEY uq_alloc (month_date, cat_id) UNIQUE KEY uq_alloc_person (month_date, cat_id, person_id),
FOREIGN KEY (person_id) REFERENCES pf_people(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_savings ( CREATE TABLE IF NOT EXISTS pf_savings (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
owner VARCHAR(50) NOT NULL, owner VARCHAR(50) NOT NULL,
month_date VARCHAR(7) NOT NULL, month_date VARCHAR(10) NOT NULL,
category VARCHAR(100) NOT NULL, category VARCHAR(100) NOT NULL,
amount DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0,
holiday_id INT DEFAULT NULL holiday_id INT DEFAULT NULL
@@ -147,9 +161,23 @@ CREATE TABLE IF NOT EXISTS pf_salary_config (
CREATE TABLE IF NOT EXISTS pf_import_rules ( CREATE TABLE IF NOT EXISTS pf_import_rules (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL UNIQUE, keyword VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL category VARCHAR(100) NOT NULL,
budget_item_id INT(11) NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `pf_advances` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`advance_date` date NOT NULL,
`payer` varchar(50) NOT NULL,
`description` varchar(255) NOT NULL,
`amount` decimal(10,2) DEFAULT 0.00,
`from_savings` tinyint(1) DEFAULT 0,
`is_resolved` tinyint(1) DEFAULT 0,
`created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_notes ( CREATE TABLE IF NOT EXISTS pf_notes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
note_type VARCHAR(100) NOT NULL, note_type VARCHAR(100) NOT NULL,
@@ -158,6 +186,29 @@ CREATE TABLE IF NOT EXISTS pf_notes (
UNIQUE KEY uq_note (note_type, reference_id) UNIQUE KEY uq_note (note_type, reference_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content LONGTEXT DEFAULT '',
tags VARCHAR(1000) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FULLTEXT KEY ft_notes (title, content, tags)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
note_id INT NOT NULL,
type ENUM('image','file','url') NOT NULL DEFAULT 'file',
filename VARCHAR(255) DEFAULT NULL,
original_name VARCHAR(255) DEFAULT NULL,
url TEXT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Voyages / Holidays ─────────────────────────────────────────────────────── -- ─── Voyages / Holidays ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_holidays ( CREATE TABLE IF NOT EXISTS pf_holidays (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -225,16 +276,153 @@ CREATE TABLE IF NOT EXISTS pf_gifts (
amount DECIMAL(8,2) DEFAULT 0 amount DECIMAL(8,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1; -- ─── Garage Manager ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_vehicles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
brand VARCHAR(100) NOT NULL,
model VARCHAR(100) NOT NULL,
year INT DEFAULT NULL,
license_plate VARCHAR(50) DEFAULT NULL,
vin VARCHAR(100) DEFAULT NULL,
fuel_type VARCHAR(50) DEFAULT 'Essence',
color VARCHAR(50) DEFAULT NULL,
purchase_date DATE DEFAULT NULL,
purchase_price DECIMAL(10,2) DEFAULT NULL,
current_km INT DEFAULT 0,
photo VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Données initiales ──────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS pf_maintenances (
-- Utilisateur admin (mot de passe : changeme → à modifier !) id INT AUTO_INCREMENT PRIMARY KEY,
INSERT IGNORE INTO pf_users (id, username, password_hash, display_name) vehicle_id INT NOT NULL,
VALUES (1, 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin'); type VARCHAR(100) NOT NULL,
description TEXT DEFAULT NULL,
date DATE NOT NULL,
km INT DEFAULT NULL,
cost DECIMAL(10,2) DEFAULT 0,
mechanic VARCHAR(100) DEFAULT NULL,
garage_name VARCHAR(100) DEFAULT NULL,
next_km INT DEFAULT NULL,
next_date DATE DEFAULT NULL,
invoice_photo VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Personnes (IDs fixes correspondant aux constantes dans config.php) CREATE TABLE IF NOT EXISTS pf_parts (
INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex'); id INT AUTO_INCREMENT PRIMARY KEY,
INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia'); vehicle_id INT DEFAULT NULL,
maintenance_id INT DEFAULT NULL,
brand VARCHAR(100) DEFAULT NULL,
reference VARCHAR(100) DEFAULT NULL,
name VARCHAR(255) NOT NULL,
category VARCHAR(100) DEFAULT 'Autre',
price DECIMAL(10,2) DEFAULT 0,
quantity INT DEFAULT 1,
unit VARCHAR(50) DEFAULT 'pièce',
supplier VARCHAR(100) DEFAULT NULL,
purchase_date DATE DEFAULT NULL,
photo VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_garage_documents (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NOT NULL,
maintenance_id INT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) DEFAULT NULL,
mime VARCHAR(120) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_gd_vehicle (vehicle_id),
KEY idx_gd_maint (maintenance_id),
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Todo ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_todo_lists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
color VARCHAR(20) DEFAULT '#3b82f6',
icon VARCHAR(10) DEFAULT '📋',
position INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_todos (
id INT AUTO_INCREMENT PRIMARY KEY,
list_id INT DEFAULT NULL,
title VARCHAR(500) NOT NULL,
notes TEXT DEFAULT NULL,
due_date DATE DEFAULT NULL,
due_time TIME DEFAULT NULL,
notified TINYINT(1) DEFAULT 0,
notified_date DATE DEFAULT NULL,
priority ENUM('none','low','medium','high') DEFAULT 'none',
done TINYINT(1) DEFAULT 0,
done_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Module Liste (Courses partagées) ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
color VARCHAR(20) DEFAULT NULL,
list_type VARCHAR(50) DEFAULT NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY,
list_id INT NOT NULL DEFAULT 1,
category_id INT DEFAULT NULL,
label VARCHAR(500) NOT NULL,
in_cart TINYINT(1) NOT NULL DEFAULT 0,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_items_list (list_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_grocery_history (
id INT AUTO_INCREMENT PRIMARY KEY,
label_hash CHAR(64) NOT NULL,
label_display VARCHAR(500) NOT NULL,
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_grocery_hist_hash (label_hash),
KEY idx_hist_last (last_used_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_list_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
icon VARCHAR(10) NOT NULL DEFAULT '🏷️',
name VARCHAR(100) NOT NULL,
position INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_item_category_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL,
category_id INT NOT NULL,
UNIQUE KEY uq_keyword (keyword)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Calendar iOS ───────────────────────────────────────────────────────────── -- ─── Calendar iOS ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_calendar_events ( CREATE TABLE IF NOT EXISTS pf_calendar_events (
@@ -264,8 +452,57 @@ CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
external_uid VARCHAR(255) NOT NULL, external_uid VARCHAR(255) NOT NULL,
external_etag VARCHAR(255) DEFAULT NULL, external_etag VARCHAR(255) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL, calendar_url VARCHAR(1024) DEFAULT NULL,
external_href VARCHAR(2048) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_calendar_event (calendar_event_id), UNIQUE KEY uq_calendar_event (calendar_event_id),
UNIQUE KEY uq_external_link (external_uid) UNIQUE KEY uq_external_link (external_uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── PrintVault ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_pv_models (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
category VARCHAR(100) DEFAULT 'Non classé',
tags VARCHAR(500) DEFAULT '',
file_type VARCHAR(20) NOT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_size INT DEFAULT 0,
dim_x DECIMAL(10,3) DEFAULT 0,
dim_y DECIMAL(10,3) DEFAULT 0,
dim_z DECIMAL(10,3) DEFAULT 0,
volume DECIMAL(10,3) DEFAULT 0,
gcode_time VARCHAR(50) DEFAULT '',
gcode_filament VARCHAR(50) DEFAULT '',
gcode_nozzle VARCHAR(20) DEFAULT '',
gcode_bed VARCHAR(20) DEFAULT '',
thumb VARCHAR(255) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_pv_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(20) DEFAULT '#8b5cf6'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_pv_categories (name, color) VALUES
('Non classé','#64748b'),('Déco','#ec4899'),('Fonctionnel','#3b82f6'),
('Mécanique','#f59e0b'),('Jouets','#10b981'),('Outils','#ef4444'),
('Architecture','#8b5cf6'),('Art','#06b6d4');
-- ─── Planka ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_planka_config (
id INT NOT NULL DEFAULT 1,
project_id VARCHAR(32),
admin_token TEXT,
token_expires_at DATETIME,
active_board_id VARCHAR(32),
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;
+164 -114
View File
@@ -31,21 +31,15 @@ CREATE TABLE IF NOT EXISTS user_calendar_integrations (
// ─── Actions ────────────────────────────────────────────────────────────────── // ─── Actions ──────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf($_POST['csrf_token'] ?? null)) { 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.']);
exit;
}
$error = "Session invalide (CSRF). Rechargez la page."; $error = "Session invalide (CSRF). Rechargez la page.";
} else { } else {
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
if ($action === 'update_family_info') {
require_once __DIR__ . '/includes/db.php';
$foyer = $pdo->query("SELECT currency, zone_scolaire FROM pf_foyer_settings WHERE id = 1")->fetch();
$currency = isset($_POST['currency']) ? trim($_POST['currency']) : ($foyer['currency'] ?? '€');
$zone = isset($_POST['zone_scolaire']) ? trim($_POST['zone_scolaire']) : ($foyer['zone_scolaire'] ?? 'C');
$stmtSave = $pdo->prepare("UPDATE pf_foyer_settings SET currency = ?, zone_scolaire = ? WHERE id = 1");
$stmtSave->execute([$currency, $zone]);
$success = "Paramètres du foyer mis à jour avec succès.";
}
if ($action === 'set_modules' && $family_id) { 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'];
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m]))); $enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
@@ -55,7 +49,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$meta_pdo->prepare("UPDATE families SET enabled_modules = ? WHERE id = ?") $meta_pdo->prepare("UPDATE families SET enabled_modules = ? WHERE id = ?")
->execute([json_encode($enabled), $family_id]); ->execute([json_encode($enabled), $family_id]);
$_SESSION['enabled_modules'] = $enabled; $_SESSION['enabled_modules'] = $enabled;
$success = "Modules mis à jour."; $success = "Modules mis à jour avec succès.";
} }
} }
@@ -65,19 +59,28 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_SESSION['app_lang'] = $lang; $_SESSION['app_lang'] = $lang;
$meta_pdo->prepare("UPDATE users SET lang = ? WHERE id = ?") $meta_pdo->prepare("UPDATE users SET lang = ? WHERE id = ?")
->execute([$lang, $user_id]); ->execute([$lang, $user_id]);
$success = "Language updated."; $success = "Langue mise à jour.";
} }
} }
if ($action === 'update_profile') { if ($action === 'update_profile') {
$display_name = trim($_POST['display_name'] ?? ''); $display_name = trim($_POST['display_name'] ?? '');
$color = trim($_POST['color'] ?? '#0891b2');
if (!$display_name) { if (!$display_name) {
$error = "Le prénom ne peut pas être vide."; $error = "Le prénom ne peut pas être vide.";
} else { } else {
$meta_pdo->prepare("UPDATE users SET display_name = ? WHERE id = ?") $meta_pdo->prepare("UPDATE users SET display_name = ? WHERE id = ?")
->execute([$display_name, $user_id]); ->execute([$display_name, $user_id]);
require_once __DIR__ . '/includes/db.php';
if (isset($pdo)) {
$stmtPeopleColor = $pdo->prepare("UPDATE pf_people SET color = ? WHERE user_id = ?");
$stmtPeopleColor->execute([$color, $user_id]);
}
$_SESSION['user']['display_name'] = $display_name; $_SESSION['user']['display_name'] = $display_name;
$success = "Profil mis à jour."; $success = "Profil mis à jour avec succès.";
} }
} }
@@ -129,7 +132,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$error = "Format non supporté (jpg, png, webp)."; $error = "Format non supporté (jpg, png, webp).";
} else { } else {
$dest = $upload_dir . 'home_bg_' . $family_id . '.' . $ext; $dest = $upload_dir . 'home_bg_' . $family_id . '.' . $ext;
// Supprimer anciens fichiers de ce family
foreach (glob($upload_dir . 'home_bg_' . $family_id . '.*') as $old) @unlink($old); foreach (glob($upload_dir . 'home_bg_' . $family_id . '.*') as $old) @unlink($old);
if (move_uploaded_file($file['tmp_name'], $dest)) { if (move_uploaded_file($file['tmp_name'], $dest)) {
$success = "Image d'accueil mise à jour."; $success = "Image d'accueil mise à jour.";
@@ -156,9 +158,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo->prepare( $pdo->prepare(
"INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('grocery_settings','history_max',?) ON DUPLICATE KEY UPDATE content=VALUES(content)" "INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('grocery_settings','history_max',?) ON DUPLICATE KEY UPDATE content=VALUES(content)"
)->execute([(string) $n]); )->execute([(string) $n]);
$success = tr('groceries_settings_saved'); $success = tr('groceries_settings_saved') ?: 'Paramètres des courses enregistrés.';
} catch (\Throwable $e) { } catch (\Throwable $e) {
$error = tr('groceries_settings_error') . ' ' . $e->getMessage(); $error = (tr('groceries_settings_error') ?: 'Erreur') . ' : ' . $e->getMessage();
} }
} }
} }
@@ -181,7 +183,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
")->execute([$user_id, $username, $encrypted, $resolvedUrl]); ")->execute([$user_id, $username, $encrypted, $resolvedUrl]);
$msg = "Connexion calendrier iOS enregistrée."; $msg = "Connexion calendrier iOS enregistrée.";
if (rtrim($resolvedUrl, '/') !== rtrim($calendarUrl, '/')) { if (rtrim($resolvedUrl, '/') !== rtrim($calendarUrl, '/')) {
$msg .= " URL du calendrier détectée automatiquement (tu avais mis la racine iCloud)."; $msg .= " URL du calendrier détectée automatiquement.";
} }
$success = $msg; $success = $msg;
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -209,7 +211,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
$success = "Connexion iCloud CalDAV valide (HTTP $code)."; $success = "Connexion iCloud CalDAV valide (HTTP $code).";
} else { } else {
$error = "Test connexion échoué (HTTP $code). Vérifie identifiant Apple, mot de passe dapp (16 caractères) et que le compte iCloud a le Calendrier activé."; $error = "Test connexion échoué (HTTP $code). Vérifiez vos identifiants.";
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$error = "Test connexion impossible: " . $e->getMessage(); $error = "Test connexion impossible: " . $e->getMessage();
@@ -221,16 +223,63 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$meta_pdo->prepare("DELETE FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'")->execute([$user_id]); $meta_pdo->prepare("DELETE FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'")->execute([$user_id]);
$success = "Connexion iOS supprimée."; $success = "Connexion iOS supprimée.";
} }
// --- GESTION DES PROFILS RATTACHÉS (Enfants, Nounous, etc.) ---
if ($action === 'add_attached_profile') {
$name = trim($_POST['profile_name'] ?? '');
$role = trim($_POST['profile_role'] ?? 'child');
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]);
$success = "Profil de {$name} ajouté avec succès.";
}
} else {
$error = "Le prénom est obligatoire.";
}
}
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é.";
}
}
}
} }
} }
// ─── Chargement données ─────────────────────────────────────────────────────── // ─── Chargement données ───────────────────────────────────────────────────────
$user = $meta_pdo->prepare("SELECT * FROM users WHERE id = ?");
$user->execute([$user_id]); if ($family_id) {
$user = $user->fetch(); require_once __DIR__ . '/includes/db.php';
}
$stmtUser = $meta_pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmtUser->execute([$user_id]);
$user = $stmtUser->fetch();
$user_color = '#0891b2';
if ($family_id && isset($pdo)) {
$stmtColorFetch = $pdo->prepare("SELECT color FROM pf_people WHERE user_id = ?");
$stmtColorFetch->execute([$user_id]);
$fetched_color = $stmtColorFetch->fetchColumn();
if ($fetched_color) {
$user_color = $fetched_color;
}
}
$family = null; $family = null;
$members = []; $members = [];
$attached_profiles = [];
if ($family_id) { if ($family_id) {
$fam = $meta_pdo->prepare("SELECT * FROM families WHERE id = ?"); $fam = $meta_pdo->prepare("SELECT * FROM families WHERE id = ?");
$fam->execute([$family_id]); $fam->execute([$family_id]);
@@ -239,6 +288,12 @@ if ($family_id) {
$mem = $meta_pdo->prepare("SELECT display_name, username, created_at, is_admin FROM users WHERE family_id = ? ORDER BY id"); $mem = $meta_pdo->prepare("SELECT display_name, username, created_at, is_admin FROM users WHERE family_id = ? ORDER BY id");
$mem->execute([$family_id]); $mem->execute([$family_id]);
$members = $mem->fetchAll(); $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");
$attached_profiles = $stmtAttached->fetchAll(PDO::FETCH_ASSOC);
}
} }
$calendarIntegration = $meta_pdo->prepare("SELECT username, calendar_url, status, last_sync_at FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'"); $calendarIntegration = $meta_pdo->prepare("SELECT username, calendar_url, status, last_sync_at FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'");
@@ -246,18 +301,13 @@ $calendarIntegration->execute([$user_id]);
$calendarIntegration = $calendarIntegration->fetch(); $calendarIntegration = $calendarIntegration->fetch();
$groceryHistoryMaxSetting = 20; $groceryHistoryMaxSetting = 20;
if ($family_id) { if ($family_id && isset($pdo)) {
require_once __DIR__ . '/includes/db.php';
if (isset($pdo)) {
try { try {
$gv = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'")->fetchColumn(); $gv = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'")->fetchColumn();
if ($gv !== false && $gv !== null && $gv !== '') { if ($gv !== false && $gv !== null && $gv !== '') {
$groceryHistoryMaxSetting = max(1, min(50, (int) $gv)); $groceryHistoryMaxSetting = max(1, min(50, (int) $gv));
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {}
// ignore
}
}
} }
$pageTitle = "Paramètres — HouseHub"; $pageTitle = "Paramètres — HouseHub";
@@ -269,14 +319,6 @@ require __DIR__ . '/header.php';
<h1 class="pf-settings-title">⚙️ Paramètres</h1> <h1 class="pf-settings-title">⚙️ Paramètres</h1>
<?php if ($success): ?>
<div class="pf-alert pf-alert--success"> <?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<?php if ($error): ?>
<div class="pf-alert pf-alert--error"> <?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<!-- ── Langue ─────────────────────────────────────────────────────────── -->
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2">🌐 Langue / Language</h2> <h2 class="pf-card-h2">🌐 Langue / Language</h2>
<form method="post" class="pf-lang-form"> <form method="post" class="pf-lang-form">
@@ -303,7 +345,6 @@ require __DIR__ . '/header.php';
</form> </form>
</section> </section>
<!-- ── Modules ────────────────────────────────────────────────────────── -->
<?php if ($family_id): ?> <?php if ($family_id): ?>
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2 pf-card-h2--tight">🧩 Modules actifs</h2> <h2 class="pf-card-h2 pf-card-h2--tight">🧩 Modules actifs</h2>
@@ -336,7 +377,7 @@ require __DIR__ . '/header.php';
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<button type="submit" class="pf-btn">Enregistrer</button> <button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</form> </form>
</section> </section>
<?php endif; ?> <?php endif; ?>
@@ -358,20 +399,12 @@ require __DIR__ . '/header.php';
</div> </div>
<button type="submit" class="pf-btn"><?= htmlspecialchars(tr('btn_save')) ?></button> <button type="submit" class="pf-btn"><?= htmlspecialchars(tr('btn_save')) ?></button>
</form> </form>
<p class="pf-muted-note" style="margin-top:1rem">
<a href="/liste.php" style="color:var(--primary);font-weight:600;"><?= htmlspecialchars(tr('mod_liste_name')) ?></a>
</p>
</section> </section>
<?php endif; ?> <?php endif; ?>
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2 pf-card-h2--tight">📱 Intégration Calendrier iOS (CalDAV)</h2> <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> <p class="pf-muted-note">Configurez ici votre calendrier iCloud pour synchroniser les événements créés dans HouseHub.</p>
<p class="pf-muted-note" style="margin-top:8px;">
Pour voir et gérer les événements : ouvrez le module
<a href="/calendar-ios.php" style="color:var(--primary);font-weight:600;">Calendrier iOS</a>
(menu du haut ou burger « Calendrier iOS », ou carte sur laccueil). Le module doit être coché dans « Modules actifs » ci-dessus.
</p>
<form method="post" class="pf-stack-md"> <form method="post" class="pf-stack-md">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="calendar_ios_save"> <input type="hidden" name="action" value="calendar_ios_save">
@@ -388,7 +421,7 @@ require __DIR__ . '/header.php';
<input type="url" name="icloud_calendar_url" class="pf-input" value="<?= htmlspecialchars($calendarIntegration['calendar_url'] ?? '') ?>" placeholder="https://caldav.icloud.com/..." required> <input type="url" name="icloud_calendar_url" class="pf-input" value="<?= htmlspecialchars($calendarIntegration['calendar_url'] ?? '') ?>" placeholder="https://caldav.icloud.com/..." required>
</div> </div>
<div class="pf-flex-gap-8"> <div class="pf-flex-gap-8">
<button type="submit" class="pf-btn">Enregistrer</button> <button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</div> </div>
</form> </form>
@@ -404,16 +437,8 @@ require __DIR__ . '/header.php';
<button type="submit" class="pf-btn btn-secondary">Déconnecter</button> <button type="submit" class="pf-btn btn-secondary">Déconnecter</button>
</form> </form>
</div> </div>
<p class="pf-muted-note" style="margin-top:10px;">
Après enregistrement, utilisez « Tester la connexion » : un message vert confirme que lURL CalDAV répond avec tes identifiants.
Sur la page Calendrier iOS, le bouton « Synchroniser » envoie les événements vers iCloud et récupère ceux créés sur liPhone.
</p>
<?php if (!empty($calendarIntegration['last_sync_at'])): ?>
<p class="pf-muted-note">Dernière synchro: <?= htmlspecialchars($calendarIntegration['last_sync_at']) ?></p>
<?php endif; ?>
</section> </section>
<!-- ── Fond page d'accueil ───────────────────────────────────────────── -->
<?php if ($family_id): <?php if ($family_id):
$bg_file = null; $bg_file = null;
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $f) { $bg_file = $f; break; } foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $f) { $bg_file = $f; break; }
@@ -451,58 +476,6 @@ require __DIR__ . '/header.php';
</section> </section>
<?php endif; ?> <?php endif; ?>
<!-- ── Famille ──────────────────────────────────────────────────────────── -->
<?php
$enabledMods = $_SESSION['enabled_modules'] ?? [];
// On cible précisément qui a besoin de quoi
$showCurrency = count(array_intersect(['budget', 'holidays', 'gifts', 'garage'], $enabledMods)) > 0;
$showZone = in_array('calendar', $enabledMods);
// Si au moins un des champs doit être affiché, on rend la section visible
if ($family_id && ($showCurrency || $showZone)):
$currentCurrency = defined('CURRENCY') ? CURRENCY : '€';
$currentZone = defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : 'C';
?>
<section class="pf-panel-card">
<h2 class="pf-card-h2">👪 Configuration du foyer</h2>
<p class="pf-muted-note">Configurez les indicateurs partagés par votre foyer.</p>
<form method="post" class="pf-stack-md">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="update_family_info">
<div class="form-row">
<?php if ($showCurrency): ?>
<div class="pf-form-group">
<label class="pf-label">Devise monétaire</label>
<input type="text" name="currency" class="pf-input" value="<?= htmlspecialchars($currentCurrency) ?>" placeholder="ex: €, $, CHF" required maxlength="10">
</div>
<?php endif; ?>
<?php if ($showZone): ?>
<div class="pf-form-group">
<label class="pf-label">Zone de vacances scolaires (France)</label>
<select name="zone_scolaire" class="pf-input" style="background:var(--bg-panel);">
<option value="A" <?= $currentZone === 'A' ? 'selected' : '' ?>>Zone A</option>
<option value="B" <?= $currentZone === 'B' ? 'selected' : '' ?>>Zone B</option>
<option value="C" <?= $currentZone === 'C' ? 'selected' : '' ?>>Zone C</option>
<option value="Autre" <?= $currentZone === 'Autre' ? 'selected' : '' ?>>Hors France / Autre</option>
</select>
</div>
<?php endif; ?>
</div>
<button type="submit" class="pf-btn">Enregistrer les paramètres du foyer</button>
</form>
</section>
<?php endif; ?>
<!-- ── Profil ──────────────────────────────────────────────────────────── -->
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2">👤 Mon profil</h2> <h2 class="pf-card-h2">👤 Mon profil</h2>
<form method="post"> <form method="post">
@@ -516,11 +489,19 @@ require __DIR__ . '/header.php';
<label class="pf-label">Prénom / Pseudo affiché</label> <label class="pf-label">Prénom / Pseudo affiché</label>
<input type="text" name="display_name" class="pf-input" value="<?= htmlspecialchars($user['display_name']) ?>" required> <input type="text" name="display_name" class="pf-input" value="<?= htmlspecialchars($user['display_name']) ?>" required>
</div> </div>
<button type="submit" class="pf-btn">Enregistrer</button>
<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>
</div>
</div>
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</form> </form>
</section> </section>
<!-- ── Mot de passe ────────────────────────────────────────────────────── -->
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2">🔒 Changer le mot de passe</h2> <h2 class="pf-card-h2">🔒 Changer le mot de passe</h2>
<form method="post"> <form method="post">
@@ -543,7 +524,6 @@ require __DIR__ . '/header.php';
</section> </section>
<?php if ($family): ?> <?php if ($family): ?>
<!-- ── Espace familial ─────────────────────────────────────────────────── -->
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2">🏠 Mon espace familial</h2> <h2 class="pf-card-h2">🏠 Mon espace familial</h2>
@@ -559,7 +539,6 @@ require __DIR__ . '/header.php';
</div> </div>
</form> </form>
<!-- Code d'invitation -->
<div class="pf-dashed-panel"> <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">Code d'invitation — partagez-le pour inviter quelqu'un</p>
<div class="pf-invite-row"> <div class="pf-invite-row">
@@ -582,7 +561,6 @@ require __DIR__ . '/header.php';
</p> </p>
</div> </div>
<!-- Membres -->
<h3 class="pf-members-heading">Membres (<?= count($members) ?>)</h3> <h3 class="pf-members-heading">Membres (<?= count($members) ?>)</h3>
<div class="pf-stack-sm"> <div class="pf-stack-sm">
<?php foreach ($members as $m): ?> <?php foreach ($members as $m): ?>
@@ -598,10 +576,61 @@ require __DIR__ . '/header.php';
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </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>
<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';
?>
<div class="pf-member-card" style="display: flex; justify-content: space-between; align-items: center;">
<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>
<?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>
<?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;">
<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>
<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>
<select name="profile_role" class="pf-input" required>
<option value="child">👶 Enfant</option>
<option value="helper">💼 Intervenant</option>
<option value="relative">👵 Proche</option>
</select>
</div>
<button type="submit" class="pf-btn pf-shrink-0" style="padding: 8px 16px;">Ajouter</button>
</form>
</section> </section>
<?php endif; ?> <?php endif; ?>
<?php if ($_SESSION['user']['is_admin']): ?> <?php if (!empty($_SESSION['user']['is_admin'])): ?>
<div class="pf-settings-admin-link"> <div class="pf-settings-admin-link">
<a href="/admin/"> Panneau d'administration</a> <a href="/admin/"> Panneau d'administration</a>
</div> </div>
@@ -610,6 +639,7 @@ require __DIR__ . '/header.php';
</div> </div>
<script> <script>
// --- INJECTION CSRF GLOBALE ---
document.querySelectorAll('form[method="post"]').forEach((form) => { document.querySelectorAll('form[method="post"]').forEach((form) => {
if (!form.querySelector('input[name="csrf_token"]')) { if (!form.querySelector('input[name="csrf_token"]')) {
const input = document.createElement('input'); const input = document.createElement('input');
@@ -620,6 +650,7 @@ document.querySelectorAll('form[method="post"]').forEach((form) => {
} }
}); });
// --- COPIE DU CODE D'INVITATION ---
function copyCode() { function copyCode() {
const code = document.getElementById('invite-code').textContent.trim(); const code = document.getElementById('invite-code').textContent.trim();
navigator.clipboard.writeText(code).then(() => { navigator.clipboard.writeText(code).then(() => {
@@ -628,6 +659,25 @@ function copyCode() {
setTimeout(() => msg.classList.remove('is-visible'), 2000); setTimeout(() => msg.classList.remove('is-visible'), 2000);
}); });
} }
// --- GESTION DES NOTIFICATIONS TOAST (Remplaçant l'ancien bandeau vert) ---
document.addEventListener('DOMContentLoaded', () => {
<?php if ($success): ?>
if (typeof showToast === 'function') {
showToast(<?= json_encode($success) ?>, 'success');
} else {
alert(<?= json_encode($success) ?>);
}
<?php endif; ?>
<?php if ($error): ?>
if (typeof showToast === 'function') {
showToast(<?= json_encode($error) ?>, 'error');
} else {
alert(<?= json_encode($error) ?>);
}
<?php endif; ?>
});
</script> </script>
<?php require __DIR__ . '/footer.php'; ?> <?php require __DIR__ . '/footer.php'; ?>