155 Commits
Author SHA1 Message Date
FeFeClochette c3eae9e1d4 estimation frais
Deploy HouseHub / deploy (push) Successful in 1s
2026-09-15 19:18:38 +02:00
FeFeClochette 4cfef07dc6 Create food planning & merge liste into food
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-15 10:58:23 +02:00
FeFeClochette 3c615e3021 doublon lang
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-10 10:00:21 +02:00
FeFeClochette 094a5b2b91 Change showtoast 2026-09-10 09:45:09 +02:00
FeFeClochette dc8a2e5244 fix categ
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-09 12:49:20 +02:00
FeFeClochette 8a7b5b6644 fix css
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-03 16:15:53 +02:00
FeFeClochette a2e0904132 recap carole
Deploy HouseHub / deploy (push) Successful in 1s
2026-09-03 13:40:50 +02:00
FeFeClochette cfe8962bd4 fix migrate
Deploy HouseHub / deploy (push) Successful in 1s
2026-09-02 20:25:50 +02:00
FeFeClochette 42ccb89b66 migrate snapshot
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-02 20:23:51 +02:00
FeFeClochette 21c9b37ae0 color select
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-02 19:13:51 +02:00
FeFeClochette c05802fc79 align account
Deploy HouseHub / deploy (push) Successful in 1s
2026-09-01 16:53:26 +02:00
FeFeClochette 348a8f1811 fix transfert perso
Deploy HouseHub / deploy (push) Successful in 2s
2026-09-01 16:31:30 +02:00
FeFeClochette 28a98f5790 fix delete category
Deploy HouseHub / deploy (push) Successful in 1s
2026-09-01 16:18:14 +02:00
FeFeClochette 32c291ff2c fix vacances
Deploy HouseHub / deploy (push) Successful in 1s
2026-08-27 16:36:24 +02:00
FeFeClochette b3139b577a remember me
Deploy HouseHub / deploy (push) Successful in 2s
2026-08-26 19:02:39 +02:00
FeFeClochette f19a7442de arrange leave type
Deploy HouseHub / deploy (push) Successful in 22s
2026-08-18 16:58:09 +02:00
FeFeClochette 536f479423 fix holidfays
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-31 13:14:50 +02:00
FeFeClochette 685c649e7c update holidays photo
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-30 18:17:03 +02:00
FeFeClochette 074d8e12d2 fix
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-30 12:50:12 +02:00
FeFeClochette f748330a1b gps expense
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-29 14:58:50 +02:00
FeFeClochette ebdbee71db planning mobil
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-28 16:37:39 +02:00
FeFeClochette 659b286967 imprv roadtrip
Deploy HouseHub / deploy (push) Successful in 3s
2026-07-28 08:51:55 +02:00
FeFeClochette ac8ebf7328 update road trip
Deploy HouseHub / deploy (push) Successful in 1s
2026-07-24 17:55:56 +02:00
FeFeClochette 49d4ec7c35 update calendar
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-22 18:36:08 +02:00
FeFeClochette c48a6ba116 pf_holidays
Deploy HouseHub / deploy (push) Successful in 4s
2026-07-20 20:39:26 +02:00
FeFeClochette 20d6c9b7b0 provisions
Deploy HouseHub / deploy (push) Successful in 2s
2026-07-07 21:26:44 +02:00
FeFeClochette d856d0a409 Deploy from parent_id to production
Deploy HouseHub / deploy (push) Successful in 4s
2026-07-01 10:30:34 +02:00
FeFeClochette 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
FeFeClochette 3e4374392a refacto : var currency and zone applied 2026-05-19 20:48:39 +02:00
percoandClaude Sonnet 4.6 ab97af0ea3 feat(liste): ajout des catégories d'articles avec détection automatique
Deploy HouseHub / deploy (push) Successful in 1s
- 22 catégories prédéfinies (Fruits & Légumes, Viande, Frais, etc.)
- Détection automatique par dictionnaire ~250 mots-clés français
- Apprentissage des corrections manuelles (pf_item_category_rules)
- Groupement des articles par section dans l'interface
- Badge catégorie cliquable sur chaque article avec popover de sélection
- API: GET categories, POST set_category
- schema_family.sql: category_id + pf_list_categories + pf_item_category_rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:25:09 +02:00
FeFeClochette cbb8f93435 Merge branch 'main' of http://nas.percolouco.com:3500/perco/HouseHub
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-19 19:16:47 +02:00
FeFeClochette 16bab06b71 Multi-tenant (p.1) 2026-05-19 19:15:38 +02:00
percoandClaude Sonnet 4.6 969bd5c2bf fix(planka): tri intra-liste par drop sur moitié haute/basse de carte
Deploy HouseHub / deploy (push) Successful in 1s
- Chaque carte est une zone de drop: moitié haute = avant, moitié basse = après
- Indicateur visuel: bordure bleue top ou bottom selon la position du curseur
- Suppression des pk-drop-before (zones 4px invisibles remplacées)
- Colonne comme zone de secours pour listes vides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:15:07 +02:00
percoandClaude Sonnet 4.6 e770ccf22b fix(planka): propagation erreurs Planka + noms étiquettes + position dans saveCard
Deploy HouseHub / deploy (push) Successful in 2s
- update_card: transmet position à Planka + retourne erreur réelle si Planka rejette
- saveCard: n'envoie listId que si changé (position requise par Planka), sinon nom/desc/dueDate seul
- Labels: affiche le nom en texte dans le badge coloré (comme Planka)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:55:35 +02:00
percoandClaude Sonnet 4.6 5828d1796d fix(planka): endpoints v2, tri intra-liste, drag&drop robuste
Deploy HouseHub / deploy (push) Successful in 2s
- create_card: POST /api/lists/{id}/cards + type=story (Planka v2 requis)
- create_list: type=active requis
- Drag & drop: zones pk-drop-before avant chaque carte pour tri intra-liste
- moveCard(): position calculée (avg prev/next) + gestion même liste
- Suppression variable dragCardId globale → dataTransfer propre

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:49:26 +02:00
percoandClaude Sonnet 4.6 0db14560a5 fix(planka): drag&drop via dataTransfer + create_list + synchro position
Deploy HouseHub / deploy (push) Successful in 2s
- Drag & drop: utilise e.dataTransfer (pas de variable globale fragile)
- dropCard prend cardId en paramètre, envoie position requis par Planka
- Nouveau bouton 'Ajouter une liste' en fin de board
- Endpoint create_list dans api.php (POST /api/boards/{id}/lists)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:43:16 +02:00
percoandClaude Sonnet 4.6 7d62aa02be fix(planka): position obligatoire pour déplacer une carte + filtre listes None
Deploy HouseHub / deploy (push) Successful in 2s
- dropCard envoie position (max+65535) car Planka l'exige pour changer listId
- Ajout vérification res.ok + toast d'erreur/succès
- Filtre les listes avec name='None' (placeholders Planka sans nom)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:30:12 +02:00
percoandClaude Sonnet 4.6 285273108c fix(planka): drag & drop toute colonne + filtre listes par boardId
Deploy HouseHub / deploy (push) Successful in 2s
- Drop target = toute la colonne .pk-cards (plus la zone invisible de 4px)
- Filtre les listes et cartes strictement par boardId pour éliminer les listes fantômes
- Indicateur visuel 'Déposer ici' sur colonnes vides
- CSS: colonne highlight en bleu pointillé au survol drag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:21:31 +02:00
percoandClaude Sonnet 4.6 3516225e56 fix: corrige include paths et get_family_pdo() dans api.php Liste
Deploy HouseHub / deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:13:49 +02:00
percoandClaude Sonnet 4.6 987ce43e52 fix: corrige parse error apostrophes fr.php et includes liste.php
Deploy HouseHub / deploy (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:10:55 +02:00
percoandClaude Sonnet 4.6 8176fc4de0 feat: remplace module Courses par Liste multi-listes nommées
Deploy HouseHub / deploy (push) Successful in 1s
- Nouveau module 'liste' : plusieurs listes nommées par espace famille
- Tabs de navigation entre les listes + création/renommage/suppression inline
- Migration automatique : existing pf_grocery_items → list_id=1 (Ma liste)
- Schema: nouvelle table pf_lists, ajout list_id sur pf_grocery_items
- Historique global partagé entre toutes les listes
- Ancien module 'groceries' conservé pour compatibilité ascendante
- Mise à jour settings.php, header.php, index.php, schema_family.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:07:02 +02:00
FeFeClochette 1a9d5d5d18 README : roadmap variabilisation
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-19 16:50:12 +02:00
perco 0ca3eef7c3 Planka: add to settings page + README section
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-18 12:54:35 +02:00
percoandClaude Sonnet 4.6 7a697213d4 Planka: add native Kanban module with proxy API and SPA board view
Deploy HouseHub / deploy (push) Successful in 1s
Adds modules/planka/api.php (PHP proxy to Planka at 10.200.33.10:1337), planka.css, and planka.php (full SPA with board selector, drag-and-drop cards, card modal with checklist). Registers planka in header nav, all 3 lang files, schema_family.sql and enables it for family 1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:52:35 +02:00
perco 00db9dedfd README: mise à jour complète — tous les modules, PrintVault, Todo Discord, stack technique
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-16 11:29:41 +02:00
percoandClaude Sonnet 4.6 f2b1c05356 PrintVault: remove GCode segment cap + fix PHP upload limits
Deploy HouseHub / deploy (push) Successful in 2s
- PHP upload: 20M → 300M, post_max: 25M → 310M, memory: 256M → 512M
- GCode parser: no segment cap (full file), flat array format for compact JSON
  491k segments = 0.8s parse, 17MB JSON on 80MB GCode file
- Viewer JS updated for flat array stride-7 format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:46:13 +02:00
percoandClaude Sonnet 4.6 237de1e529 PrintVault: raise GCode segment limit to 300k (max 500k)
Deploy HouseHub / deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:41:19 +02:00
percoandClaude Sonnet 4.6 811d186b46 PrintVault: GCode 3D path viewer with layer color gradient
Deploy HouseHub / deploy (push) Successful in 2s
- New API endpoint gcode_paths: parses G0/G1 moves, returns segments (max 30k)
- Three.js LineSegments renderer: extrusion moves colored by Z height
  Blue(low) → Cyan → Green → Yellow → Red(high), travel moves dimmed
- Legend overlay showing color scale
- GCode files now show viewer + metadata cards (layout unchanged)
- PHP syntax validated, API endpoint tested before deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:34:28 +02:00
percoandClaude Sonnet 4.6 b756517ab5 PrintVault: direct page navigation + GCode metadata cards
Deploy HouseHub / deploy (push) Successful in 2s
- Clicking a model card now navigates to /printvault-viewer.php (no popup)
- viewer page uses HouseHub header/nav (integrated look)
- GCode metadata displayed as cards: ⏱ time, 🧵 filament, 🔥 nozzle, 🟦 bed
- Two-column layout: Three.js viewer left + metadata sidebar right
- viewer.css: new stylesheet for detail page
- Removed now-unused detail/edit modals from printvault.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:05:58 +02:00
percoandClaude Sonnet 4.6 1ee0ec820c Fix PrintVault upload: unpack format f3 (not fff) + ob_end_clean() + perms
Deploy HouseHub / deploy (push) Successful in 2s
- unpack('fff',...) in PHP returns only 1 float — must use unpack('f3',...)
  to correctly extract 3 floats from binary STL vertices
- ob_end_clean() in pvOk/pvErr discards PHP warnings from output buffer
  (display_errors=On was causing warnings to corrupt JSON response)
- Upload dirs chowned to www-data so Apache can write files
- QA verified: categories(8), STL parse, DB insert, file write all pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 10:58:53 +02:00
percoandClaude Sonnet 4.6 99e135ec74 PrintVault: full native rewrite — local storage, MariaDB, Three.js viewer
Deploy HouseHub / deploy (push) Successful in 1s
- All files stored in HouseHub (/uploads/printvault/), zero dependency on external service
- PHP parsers: binary/ascii STL (dimensions + volume), 3MF (XML bounding box), GCode metadata
- Three.js viewer (STL + 3MF) in printvault-viewer.php with importmap CDN
  · OrbitControls, metallic material, environment lighting, grid floor
  · Screenshot button saves thumbnail back to HouseHub
- printvault.php: model grid + upload drag&drop + detail modal + edit modal
- API: models CRUD, categories, file serve (inline for viewer), thumb save
- pf_pv_models + pf_pv_categories tables added to schema

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 10:25:21 +02:00
percoandClaude Sonnet 4.6 7272772c9f Add PrintVault module: 3D print file manager integrated in HouseHub
Deploy HouseHub / deploy (push) Successful in 1s
- printvault.php: SPA with model grid, sidebar (categories + type filters), search
- modules/printvault/api.php: PHP proxy to PrintVault API (auth + CORS)
- modules/printvault/assets/printvault.js: model grid, upload modal, detail modal
- modules/printvault/assets/printvault.css: full light/dark theme
- Upload: drag&drop + form (name, category, tags)
- Detail modal: metadata, dimensions, GCode info, download, delete, "Voir en 3D" → PrintVault
- Integrated in header nav, home page, settings; translations fr/en/ca

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 10:14:03 +02:00
Cedric 9aa61c85df changement
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-15 09:21:25 +02:00
perco 050d8eda8b todo correctif
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 22:40:14 +02:00
perco 3e58b00c1f correctif
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 22:32:44 +02:00
perco f2c2a43d22 correction
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 22:26:00 +02:00
perco b2a3e34bbe correction garage
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-14 22:15:55 +02:00
perco f62f724046 liste V4
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 11:36:41 +02:00
perco 3a2c8eb5cf liste V3
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 10:52:59 +02:00
perco 9ca290c8ac correction liste de course
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 10:47:44 +02:00
perco e6de684f28 ajout liste de course
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-14 10:43:17 +02:00
Cedric a2e0061dde calDAV v5
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-13 18:26:09 +02:00
Cedric d9d9587ded calDAV V4
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-13 17:25:56 +02:00
Cedric 8cb9d37154 calDAV V3
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-13 17:17:40 +02:00
Cedric c62e6ef52d correction note
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-13 16:45:25 +02:00
Cedric 5b74c6da04 calDAV v3
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-13 16:20:33 +02:00
Cedric d660d2cc7a calDAV v2
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-13 15:54:10 +02:00
Cedric 912734d2cc calendrier
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-13 14:37:38 +02:00
Cedric 1c274289bf ajout calendrier ios
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-13 14:00:29 +02:00
Cedric 63833abbc7 test
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-12 17:41:22 +02:00
Cedric b3f320975b settings correction
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-12 17:23:24 +02:00
Cedric fd53b33e9f settings correction
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 16:52:27 +02:00
percoandClaude Sonnet 4.6 f68c2b7259 Todo cron: rich Discord embed with notes, priority, list + fix double execute()
Deploy HouseHub / deploy (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:48:48 +02:00
percoandClaude Sonnet 4.6 cfc115665b Todo cron: fix timezone (Europe/Paris) — container runs UTC, tasks stored in local time
Deploy HouseHub / deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:39:20 +02:00
percoandClaude Sonnet 4.6 401e98e9ee Todo cron: exact HH:MM match (like original app) + fix log path
Deploy HouseHub / deploy (push) Successful in 2s
- Match reminder_time = HH:MM exactly, runs once per minute naturally
- Log was failing silently: /var/log not writable by perco user
- Fixed crontab to log to /home/perco/househub_todo_cron.log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:06:05 +02:00
percoandClaude Sonnet 4.6 c3229cbf11 Fix todo API: PHP warning on missing due_date key corrupted JSON output
Deploy HouseHub / deploy (push) Successful in 1s
Use ?? null before ?: null for optional fields; add ob_start() as safety net.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:42:52 +02:00
percoandClaude Sonnet 4.6 9ec78ba77e Todo: fix JSON.parse error + remove quick-add bar
Deploy HouseHub / deploy (push) Successful in 1s
- api() now sends credentials + Accept/X-Requested-With headers so session
  expiry returns proper JSON 401 instead of HTML redirect
- Safer JSON parsing with readable error in console
- Remove quick-add bar (use modal only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:34:41 +02:00
percoandClaude Sonnet 4.6 606dcbf3d7 Todo: daily reminders at fixed time, no date, simplified sidebar
Deploy HouseHub / deploy (push) Successful in 2s
- Remove date field; due_time is now required
- Cron repeats daily via notified_date (resets at midnight)
- Remove Today/Upcoming/Overdue smart views from sidebar
- Task card shows bell icon + time instead of date labels
- Creation no longer sends immediate Discord notification (cron only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:09:46 +02:00
percoandClaude Sonnet 4.6 5d5ccf406d Fix cron: notify tasks with due_time but no due_date (treat as today)
Deploy HouseHub / deploy (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:06:28 +02:00
percoandClaude Sonnet 4.6 c758337ff3 Fix budget: widen month columns VARCHAR(7→10) to match Y-m-01 query format
Deploy HouseHub / deploy (push) Successful in 2s
pf_expenses.gestion_month, pf_alloc_values.month_date, pf_savings.month_date
were VARCHAR(7) but budget views query with Y-m-01 (10 chars) — converted
all imported data in househub_f2 accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:02:41 +02:00
percoandClaude Sonnet 4.6 2016407a43 Home: meaningful CTAs per module (Réviser, Griffonner, Cocher, Déballer)
Deploy HouseHub / deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:57:22 +02:00
percoandClaude Sonnet 4.6 d913f3c0eb Todo: scheduled Discord notifications via cron
Deploy HouseHub / deploy (push) Successful in 2s
- cron/todo_notify.php: checks every minute for tasks due now and sends
  Discord reminder (⏰) only when due_date+due_time is reached
- No immediate notification when due_time is set (cron handles it)
- Immediate notification still fires for tasks without a due_time
- notified column tracks whether reminder was already sent
- Resets notified=0 when a task is edited (new time picked)
- Cron runs via: docker exec househub php /var/www/html/cron/todo_notify.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:55:27 +02:00
percoandClaude Sonnet 4.6 6ef3b26153 Add due_time field to Todo tasks
Deploy HouseHub / deploy (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:52:04 +02:00
percoandClaude Sonnet 4.6 7790375c01 Fix Todo module: ID mismatches, overdue filter, Discord webhook settings
Deploy HouseHub / deploy (push) Successful in 2s
- Rewrite todo.php with correct element IDs matching todo.js
- Fix sidebar: fully dynamic rendering (no more static nav items)
- Fix quick-add: list selector added, Enter key works correctly
- Fix modals: todo-form-*, list-form-name, list-icon-opts IDs
- Fix overdue filter missing from API (list_id=overdue)
- Add settings modal (⚙️) with Discord webhook URL field
- Add settings action in API (GET/PUT via pf_notes table)
- Discord webhook: notify on task create and task done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:46:32 +02:00
percoandClaude Sonnet 4.6 b97cba6e75 Add Todo module: SPA task manager with lists, priorities and due dates
Deploy HouseHub / deploy (push) Successful in 1s
- New todo.php SPA page with sidebar (smart views + user lists)
- modules/todo/api.php: REST API for lists and todos (CRUD, filters, stats)
- modules/todo/assets/todo.js: frontend (quick add, toggle done, modals)
- modules/todo/assets/todo.css: full light/dark theme styles
- pf_todo_lists + pf_todos tables added to schema_family.sql
- Integrated in header nav, home page cards, settings module list
- Translations added in fr/en/ca

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:36:25 +02:00
percoandClaude Sonnet 4.6 5a81c1e9f0 Module Memo : intégration PercoMemo dans HouseHub
Deploy HouseHub / deploy (push) Successful in 1s
- SPA PHP/MySQL avec rendu Markdown client-side (marked.js)
- Notes par famille : titre, contenu Markdown, tags, pièces jointes
- Attachements : images, fichiers, URLs avec galerie lightbox
- Recherche full-text (LIKE) + filtrage par tag dans la sidebar
- Preview live Markdown en split-view lors de l'édition
- Drag & drop images + paste depuis presse-papier
- Tables pf_memo_notes + pf_memo_attachments dans schema_family.sql
- Migration des 2 notes existantes depuis PercoMemo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:21:57 +02:00
Cedric 3e344f4c1c debug
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 12:05:28 +02:00
Cedric 19ab75f7df test debug dark mode
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 12:00:08 +02:00
Cedric 35c3ef020d dark mode holydays
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 11:53:50 +02:00
Cedric 1ddd080c16 dark mode holydays
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 11:51:59 +02:00
Cedric c25d784471 Merge branches 'main' and 'main' of http://192.168.1.29:3500/perco/HouseHub
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-12 11:37:35 +02:00
Cedric d9d310da37 dark-mode cadeaux 2026-05-12 11:37:33 +02:00
120 changed files with 61426 additions and 6813 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
# Copier ce fichier en .env et adapter les valeurs
DB_NAME=househub
DB_USER=househub
DB_PASS=changeme
DB_ROOT_PASS=rootchangeme
+19 -4
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: |
cd /opt/container/househub BRANCH="${{ github.ref_name }}"
git pull case "$BRANCH" in
echo "✓ HouseHub mis à jour" main)
cd /opt/container/househub
git pull
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
+3
View File
@@ -1,2 +1,5 @@
househub_db_data househub_db_data
.env .env
data/.hh_app_secret
extract_source.php
.htaccess
+132 -57
View File
@@ -1,5 +1,10 @@
🦙 HouseHub OS # 🦙 HouseHub OS
Système de gestion familiale sur-mesure : Budget, Calendrier, Voyages, Cadeaux et Garage.
Système de gestion familiale sur-mesure — Budget, Calendrier, Voyages, Tâches, Courses, Notes, Impression 3D et plus.
Stack : PHP 8.2 natif · Vanilla JS · MariaDB 11 · Docker · Traefik
---
## 🚀 Déploiement (Docker) ## 🚀 Déploiement (Docker)
@@ -24,95 +29,165 @@ La base de données meta (`househub_meta`) et les permissions sont **initialisé
Aucun compte par défaut — le premier utilisateur s'inscrit via `/register.php` et choisit de créer un espace familial. Aucun compte par défaut — le premier utilisateur s'inscrit via `/register.php` et choisit de créer un espace familial.
---
## 👥 Gestion multi-tenant ## 👥 Gestion multi-tenant
HouseHub supporte plusieurs familles indépendantes sur la même instance : HouseHub supporte plusieurs familles indépendantes sur la même instance.
| URL | Description | | URL | Description |
|-----|-------------| | --------------- | ------------------------------------------------------------------- |
| `/register.php` | Créer un compte + nouvel espace, ou rejoindre un espace existant via code | | `/register.php` | Créer un compte + nouvel espace, ou rejoindre via code d'invitation |
| `/login.php` | Connexion | | `/login.php` | Connexion |
| `/settings.php` | Paramètres : profil, mot de passe, langue, modules actifs, code d'invitation | | `/settings.php` | Profil, mot de passe, langue, modules actifs, code d'invitation |
| `/admin/` | Panneau d'administration (admin uniquement) | | `/admin/` | Panneau d'administration (admin uniquement) |
| `/garage.php` | Module Garage — véhicules, entretiens, pièces |
### Inviter quelqu'un dans son espace ### Inviter quelqu'un dans son espace
1. Aller sur `/settings.php` → copier le code d'invitation 1. Aller sur `/settings.php` → copier le code d'invitation
2. La personne va sur `/register.php` → onglet "Rejoindre" → coller le code 2. La personne va sur `/register.php` → onglet "Rejoindre" → coller le code
3. Les deux comptes partagent le même environnement de données 3. Les deux comptes partagent le même environnement de données
### Rôle admin ### Rôle admin
Le premier utilisateur inscrit doit être promu admin manuellement : Le premier utilisateur inscrit doit être promu admin manuellement :
```sql ```sql
UPDATE househub_meta.users SET is_admin = 1 WHERE username = 'ton_username'; UPDATE househub_meta.users SET is_admin = 1 WHERE username = 'ton_username';
``` ```
L'admin peut ensuite promouvoir/désactiver d'autres utilisateurs depuis `/admin/`.
## 🧩 Modules
| Module | Route | Description |
|--------|-------|-------------|
| Calendrier | `/family-calendar.php` | Congés, modes de garde, planning hebdo |
| Budget | `/budget.php` | Suivi mensuel, prévisionnel, épargne |
| Voyages | `/holidays.php` | Roadtrips, cartographie, météo |
| Cadeaux | `/gift-list.php` | Noël, anniversaires, Tricount |
| Garage | `/garage.php` | Véhicules, entretiens, pièces détachées |
Les modules sont activables/désactivables par famille depuis `/settings.php`.
--- ---
📝 Présentation du projet ## 🧩 Modules
HouseHub est une application web privée conçue pour centraliser l'organisation de la tribu. Elle repose sur une stack légère (PHP natif / Vanilla JS) pour garantir rapidité et portabilité, avec un support complet de l'internationalisation (FR/CA).
🛠️ Fonctionnalités Principales Tous les modules sont activables/désactivables par famille depuis `/settings.php`.
💰 Module Budget
Suivi Mensuel : Interface de gestion des dépenses réelles avec import CSV bancaire et catégorisation automatique.
Budget Prévisionnel : Planification des revenus et répartition automatique vers les différents comptes (Commune, Livrets, Épargne). | Module | Route | Description |
| ----------------- | ---------------------- | ------------------------------------------------------------------------------- |
| 💰 Budget | `/budget.php` | Suivi mensuel, prévisionnel, épargne, import CSV bancaire |
| 📅 Calendrier | `/family-calendar.php` | Congés (CP/JRA/JA), modes de garde, planning hebdo, vacances scolaires |
| 🗓️ Calendrier iOS | `/calendar-ios.php` | Synchronisation CalDAV avec iPhone/iPad |
| 🏖️ Voyages | `/holidays.php` | Roadtrips, cartographie Leaflet, itinéraires OSRM, météo Open-Meteo |
| 🎁 Cadeaux | `/gift-list.php` | Listes Noël / anniversaires, intégration Tricount |
| 🚗 Garage | `/garage.php` | Véhicules, historique entretiens, pièces détachées |
| 🛒 Courses | `/groceries.php` | Liste de courses partagée en temps réel |
| ✅ Todo | `/todo.php` | Listes de tâches avec rappels quotidiens via Discord webhook |
| 📝 Mémo | `/memo.php` | Notes personnelles / familiales avec éditeur riche |
| 🖨️ PrintVault | `/printvault.php` | Gestionnaire de fichiers d'impression 3D (STL, 3MF, GCode) avec viewer Three.js |
| 📋 Planka | `/planka.php` | Kanban natif connecté à Planka — boards, listes, cartes, drag & drop, checklist |
Épargne : Visualisation des soldes bancaires et ventilation par postes de dépenses avec mode "Addition Rapide". ---
📅 Calendrier Familial ## 📋 Planka
Planning Hebdomadaire : Gestion visuelle des modes de garde, des congés enfants et de la maladie.
Gestion des Congés (CP/JRA/JA) : Calcul automatique des soldes restants avec snapshots de correction. Module Kanban natif connecté à l'instance Planka du NAS — affichage et gestion directement dans HouseHub sans iframe.
Vacances Scolaires : Intégration automatique via l'API Data.Education (Zone C). - **Boards** : sélecteur de boards du projet Planka lié à la famille
- **Colonnes** : listes Planka affichées en colonnes scrollables
- **Cartes** : labels colorés, date d'échéance, checklist
- **Actions** : créer, modifier, supprimer des cartes ; drag & drop entre colonnes ; cocher les tâches
- **Par famille** : chaque espace HouseHub peut pointer vers son propre projet Planka
- **Paramètres** : bouton ⚙️ pour configurer le `project_id` Planka
🏖️ Holidays & Roadtrips ---
Gestion de Voyages : Planification avec statuts (Brouillon, Confirmé, Terminé).
Moteur de Roadtrip : Cartographie interactive (Leaflet), itinéraires (OSRM) et gestion des étapes. ## 🖨️ PrintVault
Météo Intelligente : Prévisions réelles ou archives historiques (Open-Meteo). Module de gestion de fichiers d'impression 3D entièrement intégré à HouseHub.
## 🚀 Roadmap d'Optimisation - **Formats supportés** : STL (binaire/ASCII), 3MF, GCode
- **Viewer 3D** : Three.js (STL/3MF) et visualisation des trajectoires GCode avec dégradé de couleur par hauteur Z
- **Métadonnées GCode** : temps d'impression estimé, longueur filament, température buse/plateau
- **Stockage local** : fichiers sur le serveur, métadonnées en MariaDB (`pf_pv_*`)
- **Limite upload** : 300 Mo
- [x] **[Quickwin] Sécurisation AJAX (pachaFetch)** ---
- [x] **[Quickwin] Correction du piège "action"**
- [x] **[Structure] Centralisation des Constantes**
- [x] **[UX] Harmonisation Mobile**
- [x] **[Performance] Audit et Optimisation SQL**
- [ ] **[Backend] Système de Backup Automatique de la BDD**
Étape 3 : Performance & Backend ## ✅ Todo — Rappels Discord
[ ] Audit SQL : Remplacement des requêtes en boucle par des jointures.
[ ] Cache Cleanup : Script de nettoyage pour le cache de géocodage. Le module Todo supporte des rappels quotidiens via un **webhook Discord** :
📌 Patch / Versioning 1. Aller sur `/todo.php` → ⚙️ → coller l'URL du webhook Discord
2. Configurer le cron sur l'hôte :
- **v1.3.0 :** Optimisation Performance SQL. Suppression des requêtes en boucle (N+1) dans les modules Budget et Holidays. Temps de réponse divisé par 3 sur les gros historiques. ```bash
* * * * * docker exec househub php /var/www/html/cron/todo_notify.php >> /home/perco/househub_todo_cron.log 2>&1
```
* **v1.2.0 :** Modernisation de l'UI. Remplacement des alertes systèmes par des Toasts et des confirmations sur-mesure. Le cron tourne chaque minute et envoie une notification Discord à l'heure exacte configurée sur chaque tâche.
* **v1.1.0 :** Refonte de l'expérience mobile (Bottom Sheets).
v1.0.4 : Centralisation des constantes (IDs, devises) dans includes/config.php et injection dans window.CONFIG.
v1.0.3 : Migration du module Calendrier vers pachaFetch. ---
v1.0.2 : Correction systématique du bug form.action dans les fichiers Budget. ## 🛠️ Stack technique
v1.0.1 : Création de l'utilitaire global pachaFetch dans le header. | Composant | Technologie |
| -------------------- | ---------------------------------------- |
| Backend | PHP 8.2 (Apache) |
| Base de données | MariaDB 11 |
| Frontend | Vanilla JS, CSS custom (dark mode natif) |
| Viewer 3D | Three.js (ES modules via importmap CDN) |
| Cartographie | Leaflet + OSRM |
| Conteneurisation | Docker + Docker Compose |
| Reverse proxy | Traefik |
| Internationalisation | FR / CA |
v1.0.0 : Version stable initiale. ---
## 🔄 Mises à jour & Migrations SQL (Multi-Tenant)
L'architecture de HouseHub isole chaque foyer dans sa propre base de données (`househub_f1`, `househub_f2`, etc.). Lors de l'ajout de nouvelles fonctionnalités impliquant des changements de structure SQL, il faut suivre une double procédure :
1. **Pour les futures familles (Initialisation) :**
Mettre à jour le fichier `docker/schema_family.sql`. Ce fichier est lu par `register.php` à la création d'un nouvel espace.
2. **Pour les familles existantes (Migration) :**
Créer ou mettre à jour le script `migrate.php` à la racine du projet. Ce script boucle sur toutes les bases existantes référencées dans `househub_meta.families` pour y injecter les requêtes `ALTER TABLE` ou `CREATE TABLE`.
- **Exécution** : Accéder à `https://votre-domaine.com/migrate.php` avec un navigateur.
- ⚠️ **Sécurité** : Pensez à supprimer ou sécuriser l'accès à ce fichier après exécution en production.
---
## 🗺️ Roadmap de Variabilisation (Migration Multi-Tenant)
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)
[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.
[ ] Identifiant Planka : Variabiliser le project_id Planka par défaut écrit en dur dans planka/api.php.
🟡 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.
[ ] 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.
[ ] Déductions du Reste à venir (Budget) : Rendre dynamiques les libellés de catégories ciblés pour le calcul automatique du reste à venir (Estimacio escola, Estimation gasolina, etc.).
[ ] Types de carburants (Garage) : Variabiliser la liste statique des carburants dans garage.php.
🔴 Niveau 3 : Complexe (Logique métier et Schéma SQL)
[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.
[ ] 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.
[ ] Cycles de Congés : Rendre configurables les mois de début de calcul des droits aux congés (Août pour les CP, date d'anniversaire personnalisée pour les JA) par profil utilisateur.
---
## 📌 Versioning
| 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.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 |
| v1.5.0 | Modules Todo (rappels Discord), Mémo, Courses (liste partagée) |
| v1.4.0 | Synchronisation CalDAV iOS |
| v1.3.0 | Optimisation SQL — suppression des requêtes N+1, temps de réponse ÷3 |
| v1.2.0 | Modernisation UI — Toasts, confirmations sur-mesure |
| v1.1.0 | Refonte expérience mobile (Bottom Sheets) |
| v1.0.0 | Version stable initiale — Budget, Calendrier, Voyages, Cadeaux, Garage |
+2 -2
View File
@@ -76,7 +76,7 @@ require __DIR__ . '/../header.php';
</thead> </thead>
<tbody> <tbody>
<?php foreach ($families as $f): ?> <?php foreach ($families as $f): ?>
<tr style="border-bottom:1px solid #f1f5f9"> <tr style="border-bottom:1px solid var(--border-light)">
<td style="padding:10px 14px;color:#94a3b8"><?= $f['id'] ?></td> <td style="padding:10px 14px;color:#94a3b8"><?= $f['id'] ?></td>
<td style="padding:10px 14px;font-weight:600"><?= htmlspecialchars($f['name']) ?></td> <td style="padding:10px 14px;font-weight:600"><?= htmlspecialchars($f['name']) ?></td>
<td style="padding:10px 14px"><code style="font-size:0.8rem;background:#f1f5f9;padding:2px 6px;border-radius:4px"><?= $f['db_name'] ?></code></td> <td style="padding:10px 14px"><code style="font-size:0.8rem;background:#f1f5f9;padding:2px 6px;border-radius:4px"><?= $f['db_name'] ?></code></td>
@@ -135,7 +135,7 @@ require __DIR__ . '/../header.php';
</thead> </thead>
<tbody> <tbody>
<?php foreach ($users as $u): $isSelf = $u['id'] == $_SESSION['user']['id']; ?> <?php foreach ($users as $u): $isSelf = $u['id'] == $_SESSION['user']['id']; ?>
<tr style="border-bottom:1px solid #f1f5f9<?= $isSelf ? ';background:#fafff4' : '' ?>"> <tr style="border-bottom:1px solid var(--border-light)<?= $isSelf ? ';background:#fafff4' : '' ?>">
<td style="padding:10px 14px;color:#94a3b8"><?= $u['id'] ?></td> <td style="padding:10px 14px;color:#94a3b8"><?= $u['id'] ?></td>
<td style="padding:10px 14px"> <td style="padding:10px 14px">
<strong><?= htmlspecialchars($u['display_name']) ?></strong> <strong><?= htmlspecialchars($u['display_name']) ?></strong>
+36 -28
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 --- <?php
document.addEventListener('DOMContentLoaded', () => { require __DIR__ . '/modules/budget/views/settings_modal.php';
const tabsContainer = document.querySelector('.budget-tabs-container'); require __DIR__ . '/footer.php';
const activeTab = document.querySelector('.tab-item.active'); ?>
// Si on a bien un conteneur d'onglets ET un onglet actif
if (tabsContainer && activeTab) {
// On vérifie si le conteneur a un scroll horizontal possible
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'; ?>
+91
View File
@@ -0,0 +1,91 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = 'Calendrier iOS — HouseHub';
$activePage = 'calendar-ios';
$mainClass = 'pf-calendar-ios-page';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/calendar-ios/assets/calendar-ios.css">
<div class="pf-container ios-calendar-wrap">
<section class="pf-panel-card">
<div class="ios-calendar-head">
<h1>📱 <?= tr('menu_calendar_ios') ?></h1>
<div class="ios-calendar-head-actions">
<button type="button" class="ios-agenda-btn ios-agenda-btn--ghost" id="ios-sync-btn">Synchroniser</button>
</div>
</div>
<p class="ios-agenda-intro">Les événements viennent de HouseHub après synchro iCloud. <strong>Synchroniser</strong> met à jour tous les calendriers du compte ; les changements locaux partent au prochain envoi.</p>
<details class="ios-cal-prefs-details" id="ios-cal-prefs-details">
<summary class="ios-cal-prefs-summary">Calendriers affichés et couleurs</summary>
<p class="ios-cal-prefs-help">Décoche un calendrier pour le masquer dans l’agenda. La couleur est libre (repère la teinte iOS sur ton téléphone puis copie-la avec le sélecteur).</p>
<div id="ios-calendar-prefs-rows" class="ios-cal-prefs-rows"></div>
<p id="ios-calendar-prefs-msg" class="ios-cal-prefs-msg" role="status"></p>
<button type="button" class="pf-btn" id="ios-calendar-prefs-save">Enregistrer affichage &amp; couleurs</button>
</details>
</section>
<section class="pf-panel-card ios-agenda-card">
<div class="ios-agenda-toolbar">
<div class="ios-agenda-title" id="ios-agenda-title" aria-live="polite">—</div>
<div class="ios-agenda-segments" role="tablist">
<button type="button" class="ios-seg" data-ios-view="day" role="tab">Jour</button>
<button type="button" class="ios-seg ios-seg--active" data-ios-view="week" role="tab">Semaine</button>
<button type="button" class="ios-seg" data-ios-view="month" role="tab">Mois</button>
</div>
<div class="ios-agenda-nav">
<button type="button" class="ios-agenda-btn" id="ios-agenda-prev" aria-label="Période précédente">‹</button>
<button type="button" class="ios-agenda-btn" id="ios-agenda-next" aria-label="Période suivante">›</button>
<button type="button" class="ios-agenda-today" id="ios-agenda-today">Aujourd'hui</button>
<button type="button" class="ios-agenda-add" id="ios-agenda-add" title="Nouvel événement">+</button>
<button type="button" class="ios-agenda-listlink" id="ios-view-list">Liste</button>
</div>
</div>
<div id="ios-agenda-mount" class="ios-agenda-mount"></div>
</section>
<section class="pf-panel-card" id="ios-edit-panel">
<h2 class="pf-card-h2">Ajouter / Modifier un événement</h2>
<form id="ios-event-form" class="ios-form-grid">
<input type="hidden" id="ios-event-id">
<div class="pf-form-group">
<label class="pf-label">Titre</label>
<input class="pf-input" id="ios-title" required>
</div>
<div class="pf-form-group">
<label class="pf-label">Lieu</label>
<input class="pf-input" id="ios-location">
</div>
<div class="pf-form-group">
<label class="pf-label">Début</label>
<input class="pf-input" id="ios-start" type="datetime-local" required>
</div>
<div class="pf-form-group">
<label class="pf-label">Fin</label>
<input class="pf-input" id="ios-end" type="datetime-local" required>
</div>
<div class="pf-form-group ios-form-full">
<label class="pf-label">Description</label>
<textarea class="pf-input" id="ios-description"></textarea>
</div>
<div class="ios-form-full ios-form-actions">
<button class="pf-btn" type="submit">Enregistrer</button>
<button class="pf-btn btn-secondary" type="button" id="ios-form-reset">Réinitialiser</button>
</div>
</form>
</section>
<section class="pf-panel-card" id="ios-list-section" style="display:none">
<h2 class="pf-card-h2">Événements (liste)</h2>
<div id="ios-sync-status" class="ios-agenda-intro"></div>
<div id="ios-events-list" class="ios-events-list"></div>
</section>
</div>
<script src="/modules/calendar-ios/assets/calendar-ios.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+20
View File
@@ -0,0 +1,20 @@
<?php
require_once dirname(__DIR__) . '/includes/meta_db.php';
$rows = $meta_pdo->query("SELECT user_id FROM user_calendar_integrations WHERE provider='icloud_caldav' AND status='connected'")->fetchAll();
foreach ($rows as $row) {
$userId = (int)$row['user_id'];
$url = 'http://localhost/modules/calendar-ios/api.php?action=sync';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => [
'X-Requested-With: XMLHttpRequest',
// Le cron réel devra utiliser un mécanisme d'auth/session technique.
],
]);
curl_exec($ch);
curl_close($ch);
}
+117
View File
@@ -0,0 +1,117 @@
<?php
/**
* Cron: Todo daily reminder notifications via Discord webhook
* Runs every minute — sends Discord reminder when due_time is reached.
* Repeats every day at the same time (resets at midnight via notified_date).
* Usage: php /opt/container/househub/cron/todo_notify.php
*/
date_default_timezone_set('Europe/Paris');
$DB_HOST = getenv('DB_HOST') ?: 'househub-db';
$DB_USER = getenv('DB_USER') ?: 'househub';
$DB_PASS = getenv('DB_PASS') ?: 'changeme';
$DB_ROOT = getenv('DB_ROOT_PASS') ?: 'rootchangeme';
// Load .env if running from CLI outside container
$env_file = dirname(__DIR__) . '/.env';
if (file_exists($env_file)) {
foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (strpos($line, '=') !== false && $line[0] !== '#') {
[$k, $v] = explode('=', $line, 2);
$k = trim($k); $v = trim($v);
if (!getenv($k)) putenv("$k=$v");
}
}
$DB_HOST = getenv('DB_HOST') ?: $DB_HOST;
$DB_USER = getenv('DB_USER') ?: $DB_USER;
$DB_PASS = getenv('DB_PASS') ?: $DB_PASS;
$DB_ROOT = getenv('DB_ROOT_PASS') ?: $DB_ROOT;
}
// Get list of families from meta DB
try {
$meta = new PDO(
"mysql:host=$DB_HOST;dbname=househub_meta;charset=utf8mb4",
'root', $DB_ROOT,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
$families = $meta->query("SELECT id FROM families")->fetchAll(PDO::FETCH_COLUMN);
} catch (Exception $e) {
exit("Cannot connect to meta DB: " . $e->getMessage() . "\n");
}
foreach ($families as $family_id) {
$db_name = 'househub_f' . $family_id;
try {
$pdo = new PDO(
"mysql:host=$DB_HOST;dbname=$db_name;charset=utf8mb4",
$DB_USER, $DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
} catch (Exception $e) {
continue;
}
if (!$pdo->query("SHOW TABLES LIKE 'pf_todos'")->fetchColumn()) continue;
$ws = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='todo_settings' AND reference_id='webhook_discord'");
$ws->execute();
$webhook = $ws->fetchColumn();
if (!$webhook) continue;
// Daily reminder: exact HH:MM match (same logic as the original todo app)
$now = date('H:i');
$stmt = $pdo->prepare("
SELECT t.id, t.title, t.notes, t.due_time, t.priority,
l.name AS list_name, l.icon AS list_icon, l.color AS list_color
FROM pf_todos t
LEFT JOIN pf_todo_lists l ON l.id = t.list_id
WHERE t.due_time IS NOT NULL
AND TIME_FORMAT(t.due_time, '%H:%i') = ?
AND t.done = 0
");
$stmt->execute([$now]);
$priColors = ['high' => 0xef4444, 'medium' => 0xf59e0b, 'low' => 0x22c55e, 'none' => 0x3b82f6];
$priLabels = ['high' => '🔴 Haute', 'medium' => '🟡 Moyenne', 'low' => '🟢 Basse', 'none' => null];
foreach ($stmt->fetchAll() as $t) {
$time = substr($t['due_time'], 0, 5);
$priority = $t['priority'] ?? 'none';
$color = $priColors[$priority] ?? 0x3b82f6;
$fields = [];
if ($t['list_name']) {
$fields[] = ['name' => 'Liste', 'value' => $t['list_icon'] . ' ' . $t['list_name'], 'inline' => true];
}
if ($priLabels[$priority]) {
$fields[] = ['name' => 'Priorité', 'value' => $priLabels[$priority], 'inline' => true];
}
$fields[] = ['name' => 'Heure', 'value' => '⏰ ' . $time, 'inline' => true];
$embed = [
'title' => '🔔 Rappel : ' . $t['title'],
'color' => $color,
'fields' => $fields,
'footer' => ['text' => 'HouseHub Todo'],
];
if (!empty($t['notes'])) {
$embed['description'] = $t['notes'];
}
$payload = ['username' => 'HouseHub Todo', 'embeds' => [$embed]];
$ch = curl_init($webhook);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
curl_exec($ch);
curl_close($ch);
}
}
+195 -118
View File
@@ -1,133 +1,210 @@
/* ════════════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════════════
dark-mode.css — Dark mode overrides (all modules) dark-mode.css — Dark mode overrides (Cleaned & Optimized)
Chargé en dernier dans le head pour garantir la cascade. Chargé en dernier dans le head pour garantir la cascade.
════════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════════ */
/* ─── HOME PAGE ─────────────────────────────────────────────────────────────── */ /* ─── FONDS & CONTENEURS PRINCIPAUX ────────────────────────────────── */
/* Le header home.css a plus de spécificité que global.css → on le force ici */ [data-theme="dark"] .pf-card,
[data-theme="dark"] body[data-page="home"] .pf-header { [data-theme="dark"] .pf-modal-content,
background: rgba(13,17,23,.92) !important; [data-theme="dark"] .budget-tabs-container,
border-color: rgba(48,54,61,.6) !important; [data-theme="dark"] .fc-header-row,
backdrop-filter: blur(8px) !important; [data-theme="dark"] .fc-month-nav-row,
[data-theme="dark"] .fc-month-calendar-wrapper,
[data-theme="dark"] .fc-month-balances,
[data-theme="dark"] .hol-idea-card,
[data-theme="dark"] .pf-holidays__titlebar,
[data-theme="dark"] .hol-filters-row,
[data-theme="dark"] .cl-titlebar,
[data-theme="dark"] .pf-child-section,
[data-theme="dark"] .pf-child-totals-bar,
[data-theme="dark"] .pf-gift-card-compact,
[data-theme="dark"] .epargne-grid-table,
[data-theme="dark"] .sticky-col {
background: var(--bg-panel) !important;
border-color: var(--border-light) !important;
color: var(--text-main) !important;
} }
[data-theme="dark"] .owner-tabs,
[data-theme="dark"] .fc-view-controls,
[data-theme="dark"] .fc-day--other-month,
[data-theme="dark"] .fc-minimal-balance-card,
[data-theme="dark"] .pf-summary-pill,
[data-theme="dark"] .pf-filter-bar {
background: var(--bg-page) !important;
border-color: var(--border-light) !important;
color: var(--text-muted) !important;
}
/* ─── TYPOGRAPHIE GLOBALE ──────────────────────────────────────────── */
[data-theme="dark"] h1,
[data-theme="dark"] h2,
[data-theme="dark"] h3,
[data-theme="dark"] .pf-modal-title,
[data-theme="dark"] .pf-card-title,
[data-theme="dark"] .hol-main-title,
[data-theme="dark"] .pf-gift-title,
[data-theme="dark"] .budget-view h2,
[data-theme="dark"] .view-header h2,
[data-theme="dark"] .month-header-container {
color: var(--text-main) !important;
}
[data-theme="dark"] .pf-card-desc,
[data-theme="dark"] .tab-item {
color: var(--text-muted);
}
/* ─── INPUTS & SÉLECTEURS ──────────────────────────────────────────── */
[data-theme="dark"] .pf-input,
[data-theme="dark"] .pf-filter-select,
[data-theme="dark"] .hol-year-select,
[data-theme="dark"] .budget-view input,
[data-theme="dark"] .budget-view select,
[data-theme="dark"] .budget-view textarea,
[data-theme="dark"] .hol-idea-card input,
[data-theme="dark"] .hol-idea-card textarea,
[data-theme="dark"] .hol-idea-card select {
background: #1c2128 !important;
border-color: var(--border-light) !important;
color: var(--text-main) !important;
}
[data-theme="dark"] .pf-input::placeholder {
color: #484f58 !important;
}
/* ─── BOUTONS & ÉTATS ACTIFS ───────────────────────────────────────── */
[data-theme="dark"] .pf-btn.btn-secondary,
[data-theme="dark"] .fc-nav-button,
[data-theme="dark"] .fc-view-button,
[data-theme="dark"] .hol-map-toggle,
[data-theme="dark"] .cl-view-btn,
[data-theme="dark"] .pf-btn-icon-text,
[data-theme="dark"] .owner-tab {
background: var(--bg-panel) !important;
color: var(--text-muted) !important;
border-color: var(--border-light) !important;
}
[data-theme="dark"] .pf-btn.btn-secondary:hover,
[data-theme="dark"] .fc-nav-button:hover,
[data-theme="dark"] .hol-map-toggle:hover,
[data-theme="dark"] .pf-btn-icon-text:hover {
background: rgba(255, 255, 255, 0.06) !important;
color: var(--text-main) !important;
}
[data-theme="dark"] .tab-item.active,
[data-theme="dark"] .fc-view-button--active,
[data-theme="dark"] .cl-view-btn.is-active {
background: rgba(59, 130, 246, 0.15) !important;
color: var(--primary) !important;
border-color: rgba(59, 130, 246, 0.3) !important;
}
[data-theme="dark"] .owner-tab.active {
background: var(--primary) !important;
color: #fff !important;
}
/* ─── TABLES ───────────────────────────────────────────────────────── */
[data-theme="dark"] .pf-table,
[data-theme="dark"] .fc-month-table,
[data-theme="dark"] #planningTable-wrapper {
background: var(--bg-panel) !important;
color: var(--text-main);
border-color: var(--border-light) !important;
}
[data-theme="dark"] .pf-table thead th,
[data-theme="dark"] .fc-month-table th,
[data-theme="dark"] #planningTable thead th,
[data-theme="dark"] th.sticky-col,
[data-theme="dark"] .pf-child-header {
background: #1c2128 !important;
color: var(--text-muted) !important;
border-color: var(--border-light) !important;
}
[data-theme="dark"] .pf-table tbody td,
[data-theme="dark"] .fc-month-table td,
[data-theme="dark"] #planningTable tbody td {
border-color: rgba(48, 54, 61, 0.5) !important;
}
[data-theme="dark"] .pf-table tbody tr:hover td,
[data-theme="dark"] .fc-month-table td:hover:not(.fc-day--other-month) {
background: rgba(255, 255, 255, 0.04) !important;
}
[data-theme="dark"] .pf-table tfoot td {
background: #1c2128 !important;
border-color: rgba(48, 54, 61, 0.5) !important;
}
/* ─── MODULES SPÉCIFIQUES ──────────────────────────────────────────── */
/* Accueil */
[data-theme="dark"] body[data-page="home"] .pf-module-card { [data-theme="dark"] body[data-page="home"] .pf-module-card {
background: rgba(22,27,34,.92) !important; background: rgba(22, 27, 34, 0.92) !important;
border: 1px solid var(--border-light) !important; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4) !important;
box-shadow: 0 4px 15px rgba(0,0,0,.4) !important;
} }
[data-theme="dark"] body[data-page="home"] .pf-module-card:hover { [data-theme="dark"] body[data-page="home"] .pf-module-card:hover {
border-color: var(--primary) !important; border-color: var(--primary) !important;
} }
[data-theme="dark"] body[data-page="home"] .pf-card-title { color: var(--text-main); }
[data-theme="dark"] body[data-page="home"] .pf-card-desc { color: var(--text-muted); }
[data-theme="dark"] body[data-page="home"] .pf-card-cta { color: var(--primary); }
/* ─── SHARED / GLOBAL PATTERNS ──────────────────────────────────────────────── */ /* Épargne & Grilles */
[data-theme="dark"] .pf-card { background: var(--bg-panel) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; } [data-theme="dark"] .eg-header-row {
[data-theme="dark"] .pf-table { background: var(--bg-panel); color: var(--text-main); } background: #111827 !important;
[data-theme="dark"] .pf-table thead th { background: #1c2128 !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; } border-color: #30363d !important;
[data-theme="dark"] .pf-table tbody td { background: var(--bg-panel); color: var(--text-main); border-color: rgba(48,54,61,.5); } color: #e6edf3 !important;
[data-theme="dark"] .pf-table tbody tr:hover td { background: rgba(255,255,255,.03); } }
[data-theme="dark"] .pf-table tfoot, [data-theme="dark"] .eg-row:nth-child(even) {
[data-theme="dark"] .pf-table tfoot td { background: #1c2128; color: var(--text-main); border-color: rgba(48,54,61,.5); } background: rgba(255, 255, 255, 0.02);
[data-theme="dark"] .pf-modal-content { background: var(--bg-panel) !important; border: 1px solid var(--border-light) !important; color: var(--text-main) !important; } }
[data-theme="dark"] .pf-modal-title { color: var(--text-main) !important; border-color: var(--border-light) !important; } [data-theme="dark"] .eg-row:hover {
[data-theme="dark"] .pf-input, background: rgba(255, 255, 255, 0.05);
[data-theme="dark"] .pf-filter-select, }
[data-theme="dark"] .hol-year-select { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .pf-input::placeholder { color: #484f58 !important; }
[data-theme="dark"] .pf-btn.btn-secondary { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-btn.btn-secondary:hover { background: rgba(255,255,255,.06) !important; color: var(--text-main) !important; }
/* ─── BUDGET MODULE ─────────────────────────────────────────────────────────── */ /* Éléments colorés spécifiques */
[data-theme="dark"] .budget-view { color: var(--text-main); } [data-theme="dark"] .savings-table .row-extres td,
[data-theme="dark"] .budget-view h2, [data-theme="dark"] .eg-extres-row {
[data-theme="dark"] .view-header h2 { color: var(--text-main) !important; } background: rgba(245, 158, 11, 0.08) !important;
[data-theme="dark"] .budget-tabs-container { background: var(--bg-panel) !important; border-color: var(--border-light) !important; } }
[data-theme="dark"] .tab-item { background: var(--bg-panel); color: var(--text-muted); border-color: var(--border-light); }
[data-theme="dark"] .tab-item:hover { background: rgba(255,255,255,.06); color: var(--text-main); }
[data-theme="dark"] .tab-item.active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; }
[data-theme="dark"] .owner-tabs { background: var(--bg-page); border-color: var(--border-light); }
[data-theme="dark"] .owner-tab { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .owner-tab.active { background: var(--primary) !important; color: #fff !important; }
[data-theme="dark"] .month-header-container { color: var(--text-main); }
/* sticky col: uses var(--bg-panel) already but th.sticky-col uses hardcoded #f8fafc */
[data-theme="dark"] th.sticky-col { background: #1c2128 !important; color: var(--text-muted) !important; }
[data-theme="dark"] .sticky-col { background: var(--bg-panel) !important; color: var(--text-main) !important; }
[data-theme="dark"] .month-actions .btn-icon-small { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .month-actions .btn-icon-small:hover { background: rgba(255,255,255,.06) !important; }
[data-theme="dark"] .savings-table .row-extres td { background: rgba(245,158,11,.08) !important; }
[data-theme="dark"] .nens-title.theme-pol { background: rgba(30,58,138,.2) !important; color: #93c5fd !important; border-color: #3b82f6 !important; }
[data-theme="dark"] .nens-title.theme-pep { background: rgba(20,83,45,.2) !important; color: #86efac !important; border-color: #22c55e !important; }
[data-theme="dark"] .nens-table.theme-pol thead th { background: rgba(30,58,138,.15) !important; color: #93c5fd !important; }
[data-theme="dark"] .nens-table.theme-pep thead th { background: rgba(20,83,45,.15) !important; color: #86efac !important; }
/* Specific input/selects in budget */
[data-theme="dark"] .budget-view input,
[data-theme="dark"] .budget-view select,
[data-theme="dark"] .budget-view textarea { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
/* sum selected highlight */
[data-theme="dark"] .sum-selected { background: rgba(59,130,246,.1) !important; }
/* ─── FAMILY CALENDAR MODULE ────────────────────────────────────────────────── */ [data-theme="dark"] .nens-title.theme-pol {
[data-theme="dark"] .pf-family-calendar { color: var(--text-main); } background: rgba(30, 58, 138, 0.2) !important;
[data-theme="dark"] .fc-header-row { background: var(--bg-panel); border-color: var(--border-light); } color: #93c5fd !important;
[data-theme="dark"] .fc-view-controls { background: var(--bg-page) !important; border-color: var(--border-light) !important; } border-color: #3b82f6 !important;
[data-theme="dark"] .fc-view-button { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; } }
[data-theme="dark"] .fc-view-button--active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; } [data-theme="dark"] .nens-table.theme-pol thead th {
[data-theme="dark"] .fc-month-nav-row { background: var(--bg-panel); border-color: var(--border-light); } background: rgba(30, 58, 138, 0.15) !important;
[data-theme="dark"] .fc-month-nav-row h3 { color: var(--text-main) !important; } color: #93c5fd !important;
[data-theme="dark"] .fc-nav-button { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; } }
[data-theme="dark"] .fc-nav-button:hover { background: rgba(255,255,255,.06) !important; }
/* Calendar table — lots of !important in source */
[data-theme="dark"] .fc-month-table { border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-month-table th { background: #1c2128 !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-month-table td { background: var(--bg-panel) !important; border-color: rgba(48,54,61,.5) !important; }
[data-theme="dark"] .fc-month-table td:hover:not(.fc-day--other-month) { background: rgba(255,255,255,.04) !important; }
[data-theme="dark"] .fc-day--other-month { background: var(--bg-page) !important; color: var(--text-muted) !important; }
[data-theme="dark"] .fc-month-calendar-wrapper,
[data-theme="dark"] .fc-month-balances { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-minimal-balance-card { background: var(--bg-page) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .fc-min-chip { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-burn-alert { background: rgba(239,68,68,.1) !important; color: #f87171 !important; }
/* Planning table (vue planning) */
[data-theme="dark"] #planningTable-wrapper { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] #planningTable thead th { background: #1c2128 !important; color: var(--text-muted) !important; }
[data-theme="dark"] #planningTable tbody td { border-color: rgba(48,54,61,.5) !important; }
[data-theme="dark"] #planningTable tbody td.col-sticky-mois,
[data-theme="dark"] #planningTable tbody td.col-sticky-sem { background: var(--bg-panel) !important; }
[data-theme="dark"] .col-month { background: var(--bg-panel) !important; color: var(--text-main) !important; }
[data-theme="dark"] .fc-week-nav-controls { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-btn-icon-text { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-btn-icon-text:hover { background: rgba(255,255,255,.06) !important; }
/* ─── HOLIDAYS MODULE ───────────────────────────────────────────────────────── */ [data-theme="dark"] .nens-title.theme-pep {
[data-theme="dark"] .pf-holidays { color: var(--text-main); } background: rgba(20, 83, 45, 0.2) !important;
[data-theme="dark"] .pf-holidays__titlebar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; } color: #86efac !important;
[data-theme="dark"] .hol-main-title { color: var(--text-main) !important; } border-color: #22c55e !important;
[data-theme="dark"] .hol-idea-card { background: var(--bg-panel) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; box-shadow: var(--shadow) !important; } }
[data-theme="dark"] .hol-idea-card:hover { border-color: var(--primary) !important; } [data-theme="dark"] .nens-table.theme-pep thead th {
[data-theme="dark"] .hol-idea-card input, background: rgba(20, 83, 45, 0.15) !important;
[data-theme="dark"] .hol-idea-card textarea, color: #86efac !important;
[data-theme="dark"] .hol-idea-card select { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; } }
[data-theme="dark"] .hol-filters-row { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .hol-map-toggle { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .hol-map-toggle:hover { background: rgba(255,255,255,.06) !important; }
[data-theme="dark"] .hol-add-btn { background: var(--primary) !important; color: #fff !important; }
/* ─── GIFT-LIST MODULE ──────────────────────────────────────────────────────── */ [data-theme="dark"] .sum-selected {
[data-theme="dark"] .pf-gift-list { color: var(--text-main); } background: rgba(59, 130, 246, 0.1) !important;
[data-theme="dark"] .pf-gift-list h1 { color: var(--text-main) !important; } }
[data-theme="dark"] .cl-titlebar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; } [data-theme="dark"] .fc-burn-alert {
[data-theme="dark"] .cl-view-btn { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; } background: rgba(239, 68, 68, 0.1) !important;
[data-theme="dark"] .cl-view-btn.is-active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; } color: #f87171 !important;
[data-theme="dark"] .pf-filter-bar { background: var(--bg-page) !important; border-color: var(--border-light) !important; } }
[data-theme="dark"] .pf-filter-select { background: #1c2128 !important; color: var(--text-main) !important; border-color: var(--border-light) !important; } [data-theme="dark"] .month-actions .btn-icon-small {
[data-theme="dark"] .pf-child-section { background: var(--bg-panel) !important; border-color: var(--border-light) !important; } background: #1c2128 !important;
[data-theme="dark"] .pf-child-header { background: #1c2128 !important; border-color: var(--border-light) !important; } border-color: var(--border-light) !important;
[data-theme="dark"] .pf-child-header h3 { color: var(--text-main) !important; } color: var(--text-main) !important;
[data-theme="dark"] .pf-child-totals-bar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; } }
[data-theme="dark"] .pf-summary-pill { background: var(--bg-page) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; } [data-theme="dark"] .month-actions .btn-icon-small:hover {
[data-theme="dark"] .pf-gift-card-compact { background: var(--bg-panel) !important; border-color: var(--border-light) !important; } background: rgba(255, 255, 255, 0.06) !important;
[data-theme="dark"] .pf-gift-card-compact:hover { border-color: var(--primary) !important; } }
[data-theme="dark"] .pf-gift-title { color: var(--text-main) !important; }
[data-theme="dark"] .pf-gift-footer { border-color: var(--border-light) !important; }
+2
View File
@@ -12,6 +12,8 @@ services:
DB_NAME: ${DB_NAME:-househub} DB_NAME: ${DB_NAME:-househub}
DB_USER: ${DB_USER:-househub} DB_USER: ${DB_USER:-househub}
DB_PASS: ${DB_PASS:-changeme} DB_PASS: ${DB_PASS:-changeme}
# Clé applicative pour chiffrer les secrets (iCloud, etc.). Optionnel si absent : fichier data/.hh_app_secret ou dérivation DB.
APP_SECRET_KEY: ${APP_SECRET_KEY:-}
volumes: volumes:
- househub_uploads:/uploads - househub_uploads:/uploads
- /opt/container/househub:/var/www/html - /opt/container/househub:/var/www/html
+19 -1
View File
@@ -22,10 +22,28 @@ CREATE TABLE IF NOT EXISTS users (
is_admin TINYINT(1) DEFAULT 0, is_admin TINYINT(1) DEFAULT 0,
is_active TINYINT(1) DEFAULT 1, is_active TINYINT(1) DEFAULT 1,
lang VARCHAR(5) DEFAULT 'fr', lang VARCHAR(5) DEFAULT 'fr',
remember_token VARCHAR(255) DEFAULT NULL, -- 👈 NOUVEAU CHAMP ICI
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (family_id) REFERENCES families(id) FOREIGN KEY (family_id) REFERENCES families(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_calendar_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
provider VARCHAR(50) NOT NULL DEFAULT 'icloud_caldav',
username VARCHAR(255) NOT NULL,
secret_encrypted TEXT NOT NULL,
dav_principal_url VARCHAR(1024) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL,
status VARCHAR(30) DEFAULT 'connected',
last_sync_at DATETIME DEFAULT NULL,
calendar_prefs_json TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_provider (user_id, provider),
CONSTRAINT fk_calendar_integration_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Donner au user applicatif le droit de créer des DBs famille -- Donner au user applicatif le droit de créer des DBs famille
GRANT ALL PRIVILEGES ON `househub_f%`.* TO 'househub'@'%'; GRANT ALL PRIVILEGES ON `househub_f%`.* TO 'househub'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
+5 -3
View File
@@ -1,3 +1,5 @@
upload_max_filesize = 20M upload_max_filesize = 300M
post_max_size = 25M post_max_size = 310M
memory_limit = 256M memory_limit = 512M
max_execution_time = 120
max_input_time = 120
+458 -77
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,
@@ -13,9 +46,15 @@ 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 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 ──────────────────────────────────────────────────────
@@ -49,14 +88,36 @@ CREATE TABLE IF NOT EXISTS pf_leave_snapshots (
person_id INT NOT NULL, person_id INT NOT NULL,
leave_type VARCHAR(50) NOT NULL, leave_type VARCHAR(50) NOT NULL,
snapshot_date DATE NOT NULL, snapshot_date DATE NOT NULL,
remaining_balance DECIMAL(6,2) DEFAULT 0 remaining_balance DECIMAL(6,2) DEFAULT 0.00,
carry_over_balance DECIMAL(6,2) DEFAULT 0.00
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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,
carry_over_max_days DECIMAL(5,2) DEFAULT 0.00,
carry_over_deadline_month INT(11) DEFAULT 12,
anniversary_date DATE DEFAULT NULL,
INDEX (person_id),
INDEX (leave_type),
UNIQUE KEY uq_person_leave (person_id, leave_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ─── Types de Congés (Référentiel) ─────────────────────────────────────────
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,
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) VALUES
('CP', 'Congés Payés', 25.00, 6);
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,
@@ -73,83 +134,143 @@ 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,
amount DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0,
category VARCHAR(100), category VARCHAR(100),
type VARCHAR(50), type VARCHAR(50),
payment_day INT DEFAULT NULL, payment_day INT DEFAULT NULL,
is_estimate TINYINT(1) DEFAULT 0, is_estimate TINYINT(1) DEFAULT 0,
reg_month VARCHAR(7) DEFAULT NULL, is_dynamic TINYINT(1) DEFAULT 0,
dynamic_code VARCHAR(50) DEFAULT NULL,
reg_month VARCHAR(7) DEFAULT NULL,
mapping_keywords TEXT DEFAULT NULL, mapping_keywords TEXT DEFAULT NULL,
holiday_id INT DEFAULT NULL, holiday_id INT DEFAULT NULL,
is_checked TINYINT(1) DEFAULT 0, is_checked TINYINT(1) DEFAULT 0,
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_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,
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;
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,
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 (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
snapshot_date DATE NOT NULL, snapshot_date DATE NOT NULL,
amount DECIMAL(12,2) DEFAULT 0 amount DECIMAL(12,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_salary_config ( CREATE TABLE IF NOT EXISTS pf_salary_config (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL, year INT NOT NULL,
person VARCHAR(50) NOT NULL, person VARCHAR(50) NOT NULL,
salary DECIMAL(10,2) DEFAULT 0, salary DECIMAL(10,2) DEFAULT 0,
mensualite DECIMAL(10,2) DEFAULT 0, mensualite DECIMAL(10,2) DEFAULT 0,
frais_func DECIMAL(10,2) DEFAULT 0, frais_func DECIMAL(10,2) DEFAULT 0,
eco_perso DECIMAL(10,2) DEFAULT 0, eco_perso DECIMAL(10,2) DEFAULT 0,
eco_family DECIMAL(10,2) DEFAULT 0, eco_family DECIMAL(10,2) DEFAULT 0,
UNIQUE KEY uq_salary (year, person) UNIQUE KEY uq_salary (year, person)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
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,
@@ -158,6 +279,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,
@@ -168,7 +312,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 (
@@ -203,6 +373,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),
@@ -214,27 +395,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 (
@@ -246,6 +442,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,
@@ -293,3 +490,187 @@ CREATE TABLE IF NOT EXISTS pf_parts (
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL, FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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 ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY,
family_id INT NOT NULL,
created_by_user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT DEFAULT NULL,
location VARCHAR(255) DEFAULT NULL,
start_at DATETIME NOT NULL,
end_at DATETIME NOT NULL,
is_all_day TINYINT(1) DEFAULT 0,
timezone VARCHAR(64) DEFAULT 'Europe/Paris',
rrule VARCHAR(500) DEFAULT NULL,
status VARCHAR(50) DEFAULT 'confirmed',
external_uid VARCHAR(255) DEFAULT NULL,
sync_state VARCHAR(30) DEFAULT 'pending_push',
deleted_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_external_uid (external_uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
id INT AUTO_INCREMENT PRIMARY KEY,
calendar_event_id INT NOT NULL,
external_uid VARCHAR(255) NOT NULL,
external_etag VARCHAR(255) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL,
external_href VARCHAR(2048) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_calendar_event (calendar_event_id),
UNIQUE KEY uq_external_link (external_uid)
) 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;
-- ─── Planificateur de Repas ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_meals_plan (
id INT AUTO_INCREMENT PRIMARY KEY,
plan_date DATE NOT NULL,
service ENUM('lunch', 'dinner') NOT NULL,
person_id INT NOT NULL,
meal_name VARCHAR(500) DEFAULT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_meal_plan (plan_date, service, person_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+434 -144
View File
@@ -8,19 +8,96 @@ 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";
$pageCss = "/modules/family-calendar/family-calendar.css"; $pageCss = "/modules/family-calendar/family-calendar.css";
require __DIR__ . '/header.php'; require __DIR__ . '/header.php';
?> ?>
<div class="pf-container" style="max-width:100%; padding:0;"> <div class="pf-container" style="--p1-main: <?= $parents[0]['color'] ?? '#0891b2' ?>; --p2-main: <?= $parents[1]['color'] ?? '#f59e0b' ?>; max-width:100%; padding:0;">
<div class="fc-header-row"> <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>
<!-- Bloc Titre + Roue crantée -->
<div style="display: flex; gap: 10px;"> <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,11 +109,28 @@ 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">
<h3 class="pf-modal-title"><?= tr('fc_modal_holidays_title') ?></h3> <h3 class="pf-modal-title">
<button id="btnCloseHolidays" class="pf-modal-close" onclick="document.getElementById('modalHolidays').classList.remove('open'); document.body.classList.remove('no-scroll');">&times;</button> <?= tr('fc_modal_holidays_title') ?>
<?php if (defined('ZONE_SCOLAIRE') && ZONE_SCOLAIRE !== 'Autre') echo '(Zone ' . htmlspecialchars(ZONE_SCOLAIRE) . ')'; ?>
</h3>
<button id="btnCloseHolidays" class="pf-modal-close" onclick="document.getElementById('modalHolidays').classList.remove('active')">&times;</button>
</div> </div>
<div class="pf-modal-body" style="padding:0;"> <div class="pf-modal-body" style="padding:0;">
<div style="width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch;"> <div style="width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch;">
@@ -67,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>
@@ -87,172 +185,364 @@ require __DIR__ . '/header.php';
<div class="pf-form-group"> <div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_remaining_balance') ?></label> <label class="pf-label"><?= tr('fc_label_remaining_balance') ?></label>
<div style="display:flex; align-items:center;"> <div style="display:flex; align-items:center;">
<input type="number" step="0.5" id="snapBalance" class="pf-input" placeholder="<?= tr('fc_placeholder_balance') ?>" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--pf-primary);" required> <!-- 🔥 STEP 0.01 POUR DEUX DÉCIMALES STRICTES -->
<input type="number" step="0.01" id="snapBalance" class="pf-input" placeholder="<?= tr('fc_placeholder_balance') ?>" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--pf-primary);" required>
<span style="background: var(--pf-bg-lighter); border: 1px solid var(--pf-border); padding: 0 12px; height: 42px; display: flex; align-items: center; border-radius: 0 8px 8px 0; color: var(--pf-text-muted); font-size: 0.9rem; font-weight: 600;"> <span style="background: var(--pf-bg-lighter); border: 1px solid var(--pf-border); padding: 0 12px; height: 42px; display: flex; align-items: center; border-radius: 0 8px 8px 0; color: var(--pf-text-muted); font-size: 0.9rem; font-weight: 600;">
<?= tr('fc_unit_days') ?> <?= tr('fc_unit_days') ?>
</span> </span>
</div> </div>
</div> </div>
<!-- NOUVEAU BLOC : Solde reporté -->
<div class="pf-form-group" style="margin-top: 15px;">
<label class="pf-label"><?= tr('fc_label_carry_over') ?></label>
<div style="display:flex; align-items:center;">
<!-- 🔥 STEP 0.01 POUR DEUX DÉCIMALES STRICTES -->
<input type="number" step="0.01" id="snapCarryOver" class="pf-input" placeholder="ex: 2.00" value="0" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--danger);" required>
<span style="background: var(--pf-bg-lighter); border: 1px solid var(--pf-border); padding: 0 12px; height: 42px; display: flex; align-items: center; border-radius: 0 8px 8px 0; color: var(--pf-text-muted); font-size: 0.9rem; font-weight: 600;">
<?= tr('fc_unit_days') ?>
</span>
</div>
<small class="pf-muted-note"><?= tr('fc_carry_over_note') ?></small>
</div>
<!-- FOOTER INTÉGRÉ DANS LE FORMULAIRE POUR ÉVITER LE BUG DE CIBLAGE -->
<div class="pf-modal-footer" style="margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--pf-border); display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" class="pf-btn pf-btn-secondary" onclick="document.getElementById('modalSnapshot').style.display = 'none'; document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button>
<button type="submit" id="btnSubmitSnapshot" class="pf-btn pf-btn-primary"><?= tr('fc_btn_save_snap') ?></button>
</div>
</form> </form>
</div> </div>
<div class="pf-modal-footer"> </div>
<button type="button" class="pf-btn pf-btn-secondary" onclick="document.getElementById('modalSnapshot').classList.remove('open'); document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button> </div>
<button type="submit" form="formSnapshot" class="pf-btn pf-btn-primary"><?= tr('fc_btn_save_snap') ?></button>
<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; gap: 10px; flex-wrap: wrap; margin-top: 5px;">
<div style="flex: 1; min-width: 140px;">
<label class="pf-label"><?= tr('fc_carry_max') ?></label>
<input type="number" step="0.5" id="lt-carry-max" class="pf-input" placeholder="ex: 2" value="0" required>
</div>
<div style="flex: 1; min-width: 140px;">
<label class="pf-label"><?= tr('fc_carry_deadline') ?></label>
<select id="lt-carry-deadline" class="pf-input">
<option value="1"><?= tr('month_01') ?></option>
<option value="2"><?= tr('month_02') ?></option>
<option value="3"><?= tr('month_03') ?></option>
<option value="4"><?= tr('month_04') ?></option>
<option value="5"><?= tr('month_05') ?></option>
<option value="6"><?= tr('month_06') ?></option>
<option value="12"><?= tr('fc_deadline_none') ?></option>
</select>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 5px;">
<button type="button" class="pf-btn btn-secondary" onclick="resetLeaveTypeForm()"><?= tr('btn_cancel') ?></button>
<button type="button" class="pf-btn pf-btn-primary" onclick="saveLeaveType()"><?= tr('btn_save') ?></button>
</div>
</form>
</div>
</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>
</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;">
<select id="fc-select-month" class="fc-smart-select"></select>
<div id="fc-smart-date-selector" style="display:flex; align-items:center; justify-content:center; flex-grow:1; gap:6px;"> <select id="fc-select-year" class="fc-smart-select"></select>
<select id="fc-select-month" 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>
<select id="fc-select-year" class="fc-smart-select"></select> </div>
<span id="fc-multi-month-suffix" style="display:none; font-size:1.3rem; font-weight:800; color:#0f172a; margin-left:4px;"></span> <button id="fc-next-month" class="fc-nav-button">›</button>
</div> </div>
<button id="fc-next-month" class="fc-nav-button">›</button> <div class="fc-view-controls">
</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>
<div class="fc-view-controls"> <button class="fc-view-button fc-view-button--active" data-view="1month"><?= tr('fc_view_1m') ?></button>
<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" data-view="2months"><?= tr('fc_view_2m') ?></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="3months"><?= tr('fc_view_3m') ?></button>
<button class="fc-view-button" data-view="2months"><?= tr('fc_view_2m') ?></button> </div>
<button class="fc-view-button" data-view="3months"><?= tr('fc_view_3m') ?></button>
</div>
</div> </div>
<div class="fc-calendar-container"> <div class="fc-calendar-container">
<div id="fc-month-calendar" class="fc-month-calendar"></div> <div id="fc-month-calendar" class="fc-month-calendar"></div>
<div id="fc-month-selectionMenu" class="fc-selection-menu" style="display:none;"></div> <div id="fc-month-selectionMenu" class="fc-selection-menu" style="display:none;"></div>
</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">
<button id="fc-prev-school-year" class="fc-nav-button">‹</button> <button id="fc-prev-school-year" class="fc-nav-button">‹</button>
<span id="fc-current-school-year-label"></span> <span id="fc-current-school-year-label"></span>
<button id="fc-next-school-year" class="fc-nav-button">›</button> <button id="fc-next-school-year" class="fc-nav-button">›</button>
</div>
</div>
<div id="planningTable-wrapper">
<table id="planningTable">
<thead>
<tr>
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_month') ?></th>
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_week_short') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_mon') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_tue') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_wed') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_thu') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_fri') ?></th>
<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>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_centre') ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_avis') ?></span></th>
<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>
<th colspan="6" class="col-alex header-group">ALEX</th>
<th colspan="6" class="col-laia header-group">LAIA</th>
</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>
<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>
</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>
<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>
<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>
<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>
<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>
</tr>
</thead>
<tbody id="planningBody"></tbody>
</table>
<div id="selectionMenu" class="fc-selection-menu" style="display:none;"></div>
</div>
</section>
<section class="pf-section pf-section--bottom-panels">
<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>
</div>
<div class="pf-card"> <div id="planningTable-wrapper">
<table id="planningTable">
<thead>
<tr>
<th rowspan="3" class="col-month col-sticky-mois rotated-text"><span><?= tr('fc_col_month') ?></span></th>
<th rowspan="3" class="col-month col-sticky-sem rotated-text"><span><?= tr('fc_col_week') ?></span></th>
<th rowspan="3" class="col-day"><?= tr('day_mon') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_tue') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_wed') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_thu') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_fri') ?></th>
<!-- 1. Congés de l'intervenant -->
<?php foreach ($helpers as $helper):
$hLeaves = $leaveMatrix[$helper['id']] ?? [];
foreach ($hLeaves as $type):
?>
<th rowspan="3" class="col-total rotated-text"><span><?= htmlspecialchars($type) ?> <?= htmlspecialchars($helper['name']) ?></span></th>
<?php endforeach; endforeach; ?>
<!-- 2. Méthodes de garde (sans Nounou si l'intervenant est configuré) -->
<?php foreach ($activeCareModes as $mode):
if (strtolower($mode) === 'nounou' && !empty($helpers)) continue;
?>
<th rowspan="3" class="col-total rotated-text"><span><?= htmlspecialchars($mode) ?></span></th>
<?php endforeach; ?>
<!-- 3. Maladie par enfant -->
<?php foreach ($kids as $kid): ?>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('fc_menu_sick') ?> <?= htmlspecialchars($kid['name']) ?></span></th>
<?php endforeach; ?>
<?php foreach ($parents as $index => $parent):
$parentClass = ($index % 2 === 0) ? 'col-p1' : 'col-p2';
$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>
<?php foreach ($parents as $index => $parent):
$parentClass = ($index % 2 === 0) ? 'col-p1' : 'col-p2';
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
?>
<?php foreach ($pLeaves as $type): ?>
<th colspan="2" class="<?= $parentClass ?>-sub"><?= htmlspecialchars($type) ?></th>
<?php endforeach; ?>
<?php endforeach; ?>
</tr>
<tr>
<?php foreach ($parents as $index => $parent):
$parentClass = ($index % 2 === 0) ? 'col-p1' : 'col-p2';
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
?>
<?php foreach ($pLeaves as $type): ?>
<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>
</thead>
<tbody id="planningBody"></tbody>
</table>
<div id="selectionMenu" class="fc-selection-menu" style="display:none;"></div>
</div>
</section>
<?php if (!empty($helpers)): ?>
<section class="pf-section pf-mt-15">
<div id="recapHelperContent"></div>
<div style="display: none;">
<!-- Champs masqués pour maintenir la compatibilité avec la logique JS actuelle -->
<select id="recapHelperMonth" class="pf-input"></select>
<select id="recapHelperYear" class="pf-input"></select>
<input type="date" id="recapHelperStart" class="pf-input">
<input type="date" id="recapHelperEnd" class="pf-input">
</div>
</section>
<?php endif; ?>
<section class="pf-section pf-section--bottom-panels" style="margin-top: 1.5rem;">
<div class="pf-card">
<h2 class="pf-card-title"><?= tr('fc_legend_title') ?></h2> <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 selon les congés paramétrés) -->
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-centre"></div><span><?= tr('leg_centre') ?></span></div> <?php
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-avis"></div><span><?= tr('leg_avis') ?></span></div> $helperColorIndex = 0;
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-pep-sick"></div><span><?= tr('leg_pep_sick') ?></span></div> foreach ($helpers as $helper):
$hLeaves = $leaveMatrix[$helper['id']] ?? [];
foreach ($hLeaves as $type):
?>
<div class="pf-legend-item">
<div class="pf-legend-color fc-bg-palette-<?= $helperColorIndex % 8 ?>"></div>
<span><?= htmlspecialchars($type) ?> <?= htmlspecialchars($helper['name']) ?></span>
</div>
<?php $helperColorIndex++; endforeach; endforeach; ?>
<!-- Modes de garde (On masque "Nounou" si l'intervenant gère lui-même ses présences) -->
<?php foreach ($activeCareModes as $index => $mode):
$modeLower = strtolower($mode);
if ($modeLower === 'nounou' && !empty($helpers)) continue;
if ($modeLower === 'avis'): ?>
<div class="pf-legend-item">
<img src="/modules/family-calendar/assets/img/avis.svg" style="width:16px;height:16px;object-fit:contain;margin-right:6px;" alt="Avis">
<span><?= htmlspecialchars($mode) ?></span>
</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 (Une puce par enfant) -->
<?php foreach ($kids as $kid):
$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>
</div>
</section> </section>
</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'; ?>
+53
View File
@@ -0,0 +1,53 @@
<?php
// food.php
require __DIR__ . '/includes/auth.php';
require __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/i18n.php';
require_login('/login.php');
// Gestion de l'onglet actif
$tab = $_GET['tab'] ?? 'meals';
$pageTitle = tr('mod_food_name');
$activePage = "food";
$pageCss = "/modules/food/food.css";
require __DIR__ . '/header.php';
?>
<div class="pf-container">
<div class="pf-hero budget-hero">
<div class="budget-title-group">
<h1>🍳 <?= tr('mod_food_name') ?></h1>
</div>
<nav class="budget-tabs-container">
<a href="?tab=meals" class="tab-item <?= $tab == 'meals' ? 'active' : '' ?>">
<span class="tab-icon">🍽️</span>
<span><?= tr('food_tab_meals') ?></span>
</a>
<a href="?tab=liste" class="tab-item <?= $tab == 'liste' ? 'active' : '' ?>">
<span class="tab-icon">🛒</span>
<span><?= tr('food_tab_list') ?></span>
</a>
</nav>
</div>
<section class="pf-section">
<?php
$allowedTabs = ['meals', 'liste'];
if (in_array($tab, $allowedTabs)) {
$viewPath = __DIR__ . "/modules/food/views/{$tab}.php";
if (file_exists($viewPath)) {
require $viewPath;
} else {
echo "<p>Vue introuvable : " . htmlspecialchars($tab) . "</p>";
}
} else {
echo "<p>" . tr('error_occured') . "</p>";
}
?>
</section>
</div>
<?php require __DIR__ . '/footer.php'; ?>
+60 -12
View File
@@ -28,15 +28,15 @@ require __DIR__ . '/header.php';
<div class="stat-pill amber"><div class="label">Pièces</div><div class="value" id="stat-parts">--</div></div> <div class="stat-pill amber"><div class="label">Pièces</div><div class="value" id="stat-parts">--</div></div>
<div class="stat-pill red"><div class="label">Coût total</div><div class="value" id="stat-cost">--</div></div> <div class="stat-pill red"><div class="label">Coût total</div><div class="value" id="stat-cost">--</div></div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 380px;gap:1.5rem;align-items:start"> <div class="garage-dash-layout">
<div> <div class="garage-dash-main">
<div class="card-header" style="margin-bottom:.75rem"> <div class="card-header garage-card-header" style="margin-bottom:.75rem">
<h2 style="font-size:1rem;font-weight:600">Mes véhicules</h2> <h2 style="font-size:1rem;font-weight:600">Mes véhicules</h2>
<button class="btn btn-primary btn-sm" onclick="openAddVehicle()">+ Ajouter</button> <button class="btn btn-primary btn-sm" onclick="openAddVehicle()">+ Ajouter</button>
</div> </div>
<div id="dashboard-vehicles" class="vehicles-grid"></div> <div id="dashboard-vehicles" class="vehicles-grid"></div>
</div> </div>
<div class="card"> <div class="card garage-dash-aside">
<div class="card-header"><div class="card-title">🔔 Rappels & prochains entretiens</div></div> <div class="card-header"><div class="card-title">🔔 Rappels & prochains entretiens</div></div>
<div id="reminders-list"></div> <div id="reminders-list"></div>
</div> </div>
@@ -47,7 +47,7 @@ require __DIR__ . '/header.php';
<!-- ── VÉHICULES ──────────────────────────────────────────────────────────── --> <!-- ── VÉHICULES ──────────────────────────────────────────────────────────── -->
<div id="page-vehicles" class="page"> <div id="page-vehicles" class="page">
<div class="container"> <div class="container">
<div class="card-header" style="margin-bottom:1rem"> <div class="card-header garage-card-header" style="margin-bottom:1rem">
<h1 style="font-size:1.2rem;font-weight:700">🚗 Mes véhicules</h1> <h1 style="font-size:1.2rem;font-weight:700">🚗 Mes véhicules</h1>
<button class="btn btn-primary" onclick="openAddVehicle()">+ Ajouter un véhicule</button> <button class="btn btn-primary" onclick="openAddVehicle()">+ Ajouter un véhicule</button>
</div> </div>
@@ -62,22 +62,38 @@ require __DIR__ . '/header.php';
<button class="btn btn-secondary btn-sm" onclick="navigate('vehicles')">← Retour</button> <button class="btn btn-secondary btn-sm" onclick="navigate('vehicles')">← Retour</button>
</div> </div>
<div id="vehicle-header"></div> <div id="vehicle-header"></div>
<div class="tabs"> <div class="tabs garage-tabs">
<button class="tab-btn active" data-tab="maintenances" onclick="switchTab('maintenances')">🔧 Entretiens <span id="maintenance-total" style="color:var(--muted);font-size:.78rem"></span></button> <button class="tab-btn active" data-tab="maintenances" onclick="switchTab('maintenances')">🔧 Entretiens <span id="maintenance-total" style="color:var(--muted);font-size:.78rem"></span></button>
<button class="tab-btn" data-tab="parts" onclick="switchTab('parts')">🔩 Pièces <span id="parts-total-vehicle" style="color:var(--muted);font-size:.78rem"></span></button> <button class="tab-btn" data-tab="parts" onclick="switchTab('parts')">🔩 Pièces <span id="parts-total-vehicle" style="color:var(--muted);font-size:.78rem"></span></button>
<button class="tab-btn" data-tab="documents" onclick="switchTab('documents')">📎 Documents <span id="vehicle-docs-tab-count" style="color:var(--muted);font-size:.78rem"></span></button>
</div> </div>
<div id="tab-maintenances" class="tab-pane active"> <div id="tab-maintenances" class="tab-pane active">
<div style="margin-bottom:1rem;display:flex;justify-content:flex-end"> <div class="garage-tab-actions">
<button class="btn btn-primary btn-sm" onclick="openAddMaintenance()">+ Ajouter un entretien</button> <button class="btn btn-primary btn-sm" onclick="openAddMaintenance()">+ Ajouter un entretien</button>
</div> </div>
<div id="maintenance-list"></div> <div id="maintenance-list"></div>
</div> </div>
<div id="tab-parts" class="tab-pane"> <div id="tab-parts" class="tab-pane">
<div style="margin-bottom:1rem;display:flex;justify-content:flex-end"> <div class="garage-tab-actions">
<button class="btn btn-primary btn-sm" onclick="openAddPart()">+ Ajouter une pièce</button> <button class="btn btn-primary btn-sm" onclick="openAddPart()">+ Ajouter une pièce</button>
</div> </div>
<div id="parts-list-vehicle"></div> <div id="parts-list-vehicle"></div>
</div> </div>
<div id="tab-documents" class="tab-pane">
<div class="garage-doc-upload-bar">
<div class="form-group" style="flex:1;min-width:0;margin:0">
<label class="form-label" for="vehicle-doc-label">Libellé (optionnel)</label>
<input type="text" class="form-control" id="vehicle-doc-label" placeholder="Carte grise, assurance…">
</div>
<div class="form-group" style="margin:0;flex:1;min-width:140px">
<label class="form-label" for="vehicle-doc-file">Fichier</label>
<input type="file" class="form-control" id="vehicle-doc-file" accept=".pdf,.png,.jpg,.jpeg,.webp,.doc,.docx,.xls,.xlsx,.txt">
</div>
<button type="button" class="btn btn-primary btn-sm garage-doc-upload-btn" onclick="uploadVehicleDocument()">Envoyer</button>
</div>
<p class="garage-doc-hint">PDF, images, Office… · max 12 Mo</p>
<div id="vehicle-documents-list"></div>
</div>
</div> </div>
</div> </div>
@@ -88,6 +104,28 @@ require __DIR__ . '/header.php';
<button class="btn btn-secondary btn-sm" id="maintenance-back-btn" onclick="navigate('vehicle',{id:currentVehicleId})">← Retour au véhicule</button> <button class="btn btn-secondary btn-sm" id="maintenance-back-btn" onclick="navigate('vehicle',{id:currentVehicleId})">← Retour au véhicule</button>
</div> </div>
<div id="maintenance-detail-header"></div> <div id="maintenance-detail-header"></div>
<div class="card garage-docs-card" style="margin-top:1rem">
<div class="card-header garage-card-header">
<span class="card-title">📎 Documents & factures (cette révision)</span>
</div>
<div style="padding:0 1.25rem 1rem">
<div class="garage-doc-upload-bar">
<div class="form-group" style="flex:1;min-width:0;margin:0">
<label class="form-label" for="maint-doc-label">Libellé (ex. facture garage)</label>
<input type="text" class="form-control" id="maint-doc-label" placeholder="Facture, devis…">
</div>
<div class="form-group" style="margin:0;flex:1;min-width:140px">
<label class="form-label" for="maint-doc-file">Fichier</label>
<input type="file" class="form-control" id="maint-doc-file" accept=".pdf,.png,.jpg,.jpeg,.webp,.doc,.docx,.xls,.xlsx,.txt">
</div>
<button type="button" class="btn btn-primary btn-sm garage-doc-upload-btn" onclick="uploadMaintenanceDocument()">Envoyer</button>
</div>
<p class="garage-doc-hint">PDF, images… · max 12 Mo</p>
<div id="maintenance-documents-list"></div>
</div>
</div>
<div class="card" style="padding:0;margin-top:1.5rem"> <div class="card" style="padding:0;margin-top:1.5rem">
<div class="card-header"> <div class="card-header">
<span class="card-title">🔩 Pièces utilisées <span id="maint-parts-count" class="badge badge-gray" style="margin-left:.5rem"></span></span> <span class="card-title">🔩 Pièces utilisées <span id="maint-parts-count" class="badge badge-gray" style="margin-left:.5rem"></span></span>
@@ -127,7 +165,7 @@ require __DIR__ . '/header.php';
<!-- ── TOUS LES ENTRETIENS ─────────────────────────────────────────────────── --> <!-- ── TOUS LES ENTRETIENS ─────────────────────────────────────────────────── -->
<div id="page-maintenances-all" class="page"> <div id="page-maintenances-all" class="page">
<div class="container"> <div class="container">
<div class="card-header" style="margin-bottom:1rem"> <div class="card-header garage-card-header" style="margin-bottom:1rem">
<div> <div>
<h1 style="font-size:1.2rem;font-weight:700">🔧 Tous les entretiens</h1> <h1 style="font-size:1.2rem;font-weight:700">🔧 Tous les entretiens</h1>
<p style="color:var(--muted);font-size:.85rem"><span id="all-maint-count">0</span> entretiens · Total : <span id="all-maint-total">--</span></p> <p style="color:var(--muted);font-size:.85rem"><span id="all-maint-count">0</span> entretiens · Total : <span id="all-maint-total">--</span></p>
@@ -140,7 +178,7 @@ require __DIR__ . '/header.php';
<!-- ── TOUTES LES PIÈCES ──────────────────────────────────────────────────── --> <!-- ── TOUTES LES PIÈCES ──────────────────────────────────────────────────── -->
<div id="page-parts" class="page"> <div id="page-parts" class="page">
<div class="container"> <div class="container">
<div class="card-header" style="margin-bottom:1rem"> <div class="card-header garage-card-header" style="margin-bottom:1rem">
<div> <div>
<h1 style="font-size:1.2rem;font-weight:700">🔩 Toutes les pièces</h1> <h1 style="font-size:1.2rem;font-weight:700">🔩 Toutes les pièces</h1>
<p style="color:var(--muted);font-size:.85rem"><span id="all-parts-count">0</span> pièces · Total : <span id="all-parts-total">--</span></p> <p style="color:var(--muted);font-size:.85rem"><span id="all-parts-count">0</span> pièces · Total : <span id="all-parts-total">--</span></p>
@@ -179,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-group"><label class="form-label">VIN</label><input class="form-control" id="vehicle-vin" name="vin" placeholder="VF1..."></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">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>";
?>
+564 -124
View File
@@ -8,66 +8,55 @@ 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 = [];
$adultsInView = []; $adultsInView = [];
foreach ($gifts as $g) { 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; }
@@ -86,7 +75,7 @@ for ($i = 0; $i < $countPeople; $i++) {
for ($j = $i + 1; $j < $countPeople; $j++) { for ($j = $i + 1; $j < $countPeople; $j++) {
$a = $people[$i]; $b = $people[$j]; $a = $people[$i]; $b = $people[$j];
$net = $matrix[$a][$b] - $matrix[$b][$a]; $net = $matrix[$a][$b] - $matrix[$b][$a];
if ($net > 0.01) { $settlements[] = ['from' => $a, 'to' => $b, 'amount' => $net]; } if ($net > 0.01) { $settlements[] = ['from' => $a, 'to' => $b, 'amount' => $net]; }
elseif ($net < -0.01) { $settlements[] = ['from' => $b, 'to' => $a, 'amount' => -$net]; } elseif ($net < -0.01) { $settlements[] = ['from' => $b, 'to' => $a, 'amount' => -$net]; }
} }
} }
@@ -94,26 +83,27 @@ 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)">
👦 <span id="ms-child-label"><?= tr('gift_filter_all_children') ?></span> 👦 <span id="ms-child-label"><?= tr('gift_filter_all_children') ?></span>
</div> </div>
<div class="pf-ms-dropdown" id="ms-child-list"> <div class="pf-ms-dropdown" id="ms-child-list">
<label class="pf-ms-option is-all"> <label class="pf-ms-option is-all">
<input type="checkbox" value="all" checked onchange="handleMSChange(this, 'child')"> <input type="checkbox" value="all" checked onchange="handleMSChange(this, 'child')">
<?= tr('gift_filter_all_children') ?> <?= tr('gift_filter_all_children') ?>
</label> </label>
<?php foreach($children as $c): ?> <?php foreach($children as $c): ?>
@@ -128,7 +118,7 @@ require __DIR__ . '/header.php';
</div> </div>
<div class="pf-ms-dropdown" id="ms-adult-list"> <div class="pf-ms-dropdown" id="ms-adult-list">
<label class="pf-ms-option is-all"> <label class="pf-ms-option is-all">
<input type="checkbox" value="all" checked onchange="handleMSChange(this, 'adult')"> <input type="checkbox" value="all" checked onchange="handleMSChange(this, 'adult')">
<?= tr('gift_filter_all_adults') ?> <?= tr('gift_filter_all_adults') ?>
</label> </label>
<?php foreach($allAdultsList as $a): ?> <?php foreach($allAdultsList as $a): ?>
@@ -139,24 +129,28 @@ 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">
<h3>👦 <?= htmlspecialchars($child) ?></h3> <h3>👦 <?= htmlspecialchars($child) ?></h3>
<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,15 +162,15 @@ 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']) ?>">
<div> <div>
<h4 class="pf-gift-title"> <h4 class="pf-gift-title">
<?= htmlspecialchars($g['gift_description']) ?> <?= htmlspecialchars($g['gift_description']) ?>
<?php if($g['product_link']): ?><a href="<?= htmlspecialchars($g['product_link']) ?>" target="_blank" class="pf-gift-link">🔗</a><?php endif; ?> <?php if($g['product_link']): ?><a href="<?= htmlspecialchars($g['product_link']) ?>" target="_blank" class="pf-gift-link">🔗</a><?php endif; ?>
</h4> </h4>
<span class="pf-pill-adult">👤 <?= htmlspecialchars($g['adult_name']) ?></span> <span class="pf-pill-adult">👤 <?= htmlspecialchars($g['adult_name']) ?></span>
@@ -201,41 +195,42 @@ require __DIR__ . '/header.php';
<?php endforeach; ?> <?php endforeach; ?>
</section> </section>
<section class="pf-section pf-section--panel" style="background:#f8fafc; 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; background:white; padding:12px; border-radius:8px; border:1px solid #cbd5e1;"> <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,27 +241,28 @@ 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">
<input type="hidden" name="year" value="<?= $year ?>"> <input type="hidden" name="year" value="<?= $year ?>">
<input type="hidden" name="child_name" id="modalChild"> <input type="hidden" name="child_name" id="modalChild">
<input type="hidden" name="occasion" id="modalOccasion"> <input type="hidden" name="occasion" id="modalOccasion">
<input type="hidden" name="action" id="modalAction"> <input type="hidden" name="action" id="modalAction">
<input type="hidden" name="gift_id" id="modalGiftId"> <input type="hidden" name="gift_id" id="modalGiftId">
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_col_adult') ?></label><select name="adult_name" id="modalAdult" class="pf-input" required></select></div> <div class="pf-form-group"><label class="pf-label"><?= tr('gift_col_adult') ?></label><select name="adult_name" id="modalAdult" 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_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; border-top:1px solid #e2e8f0; 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
@@ -297,7 +378,7 @@ document.addEventListener('click', e => {
function handleMSChange(cb, type) { function handleMSChange(cb, type) {
const container = document.getElementById('ms-' + type + '-list'); const container = document.getElementById('ms-' + type + '-list');
if (!container) return; if (!container) return;
if (cb.value === 'all' && cb.checked) { if (cb.value === 'all' && cb.checked) {
container.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(i => i.checked = false); container.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(i => i.checked = false);
} else if (cb.checked) { } else if (cb.checked) {
@@ -310,10 +391,10 @@ function handleMSChange(cb, type) {
if (allCb) allCb.checked = true; if (allCb) allCb.checked = true;
} }
} }
const checkedSpecifics = container.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])'); const checkedSpecifics = container.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])');
const labelEl = document.getElementById('ms-' + type + '-label'); const labelEl = document.getElementById('ms-' + type + '-label');
if (labelEl) { if (labelEl) {
if (checkedSpecifics.length === 0) { if (checkedSpecifics.length === 0) {
let defaultText = 'Tous'; let defaultText = 'Tous';
@@ -335,23 +416,23 @@ function applyGiftFilters() {
const cList = document.getElementById('ms-child-list'); const cList = document.getElementById('ms-child-list');
const aList = document.getElementById('ms-adult-list'); const aList = document.getElementById('ms-adult-list');
if (!cList || !aList) return; if (!cList || !aList) return;
const allChildCb = cList.querySelector('input[value="all"]'); const allChildCb = cList.querySelector('input[value="all"]');
const cAll = allChildCb ? allChildCb.checked : true; const cAll = allChildCb ? allChildCb.checked : true;
const cVals = Array.from(cList.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])')).map(i => i.value); const cVals = Array.from(cList.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])')).map(i => i.value);
const allAdultCb = aList.querySelector('input[value="all"]'); const allAdultCb = aList.querySelector('input[value="all"]');
const aAll = allAdultCb ? allAdultCb.checked : true; const aAll = allAdultCb ? allAdultCb.checked : true;
const aVals = Array.from(aList.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])')).map(i => i.value); const aVals = Array.from(aList.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])')).map(i => i.value);
document.querySelectorAll('.js-occ-section').forEach(occSec => { document.querySelectorAll('.js-occ-section').forEach(occSec => {
let occHasVisible = false; let occHasVisible = false;
occSec.querySelectorAll('.js-child').forEach(childSec => { occSec.querySelectorAll('.js-child').forEach(childSec => {
const cName = childSec.getAttribute('data-name'); const cName = childSec.getAttribute('data-name');
const matchChild = cAll || (cVals.indexOf(cName) !== -1); const matchChild = cAll || (cVals.indexOf(cName) !== -1);
let childHasVisibleCard = false; let childHasVisibleCard = false;
if (matchChild) { if (matchChild) {
childSec.querySelectorAll('.js-gift-card').forEach(card => { childSec.querySelectorAll('.js-gift-card').forEach(card => {
const aName = card.getAttribute('data-adult'); const aName = card.getAttribute('data-adult');
@@ -359,36 +440,34 @@ function applyGiftFilters() {
card.style.display = matchAdult ? 'flex' : 'none'; card.style.display = matchAdult ? 'flex' : 'none';
if (matchAdult) childHasVisibleCard = true; if (matchAdult) childHasVisibleCard = true;
}); });
childSec.querySelectorAll('.js-pill-adult').forEach(pill => { childSec.querySelectorAll('.js-pill-adult').forEach(pill => {
const aName = pill.getAttribute('data-adult'); const aName = pill.getAttribute('data-adult');
const matchAdult = aAll || (aVals.indexOf(aName) !== -1); const matchAdult = aAll || (aVals.indexOf(aName) !== -1);
pill.style.opacity = matchAdult ? '1' : '0.2'; pill.style.opacity = matchAdult ? '1' : '0.2';
}); });
const empty = childSec.querySelector('.js-empty-state'); const empty = childSec.querySelector('.js-empty-state');
if (empty) empty.style.display = childHasVisibleCard ? 'none' : 'block'; if (empty) empty.style.display = childHasVisibleCard ? 'none' : 'block';
childSec.style.display = 'block'; childSec.style.display = 'block';
occHasVisible = true; occHasVisible = true;
} else { } else {
childSec.style.display = 'none'; childSec.style.display = 'none';
} }
}); });
occSec.style.display = occHasVisible ? 'block' : 'none'; occSec.style.display = occHasVisible ? 'block' : 'none';
}); });
} }
// ========================================== // ==========================================
// 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,23 +497,22 @@ 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;
const occCode = btnAdd.dataset.occ; const occCode = btnAdd.dataset.occ;
const adultsList = JSON.parse(btnAdd.dataset.adults || '[]'); const adultsList = JSON.parse(btnAdd.dataset.adults || '[]');
document.getElementById('modalAction').value = 'create'; document.getElementById('modalAction').value = 'create';
document.getElementById('modalGiftId').value = ''; document.getElementById('modalGiftId').value = '';
document.getElementById('modalChild').value = childName; document.getElementById('modalChild').value = childName;
document.getElementById('modalOccasion').value = occCode; document.getElementById('modalOccasion').value = occCode;
document.getElementById('modalDesc').value = ''; document.getElementById('modalDesc').value = '';
document.getElementById('modalAmount').value = ''; document.getElementById('modalAmount').value = '';
document.getElementById('modalLink').value = ''; document.getElementById('modalLink').value = '';
populateSelects(adultsList); populateSelects(adultsList);
if (adultSelect && payerSelect) { if (adultSelect && payerSelect) {
if (adultsList.indexOf('Laia') !== -1) { if (adultsList.indexOf('Laia') !== -1) {
adultSelect.value = 'Laia'; payerSelect.value = 'Laia'; adultSelect.value = 'Laia'; payerSelect.value = 'Laia';
@@ -448,21 +523,20 @@ document.body.addEventListener('click', async function(e) {
const titleStr = window.I18N && window.I18N['gift_modal_title_add'] ? window.I18N['gift_modal_title_add'] : 'Ajouter pour %s'; const titleStr = window.I18N && window.I18N['gift_modal_title_add'] ? window.I18N['gift_modal_title_add'] : 'Ajouter pour %s';
document.getElementById('modalTitle').textContent = titleStr.replace('%s', childName); document.getElementById('modalTitle').textContent = titleStr.replace('%s', childName);
if (modal) { modal.classList.add('open'); document.body.classList.add('no-scroll'); } if (modal) { modal.classList.add('open'); document.body.classList.add('no-scroll'); }
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 || '{}');
document.getElementById('modalAction').value = 'update'; document.getElementById('modalAction').value = 'update';
document.getElementById('modalGiftId').value = data.id; document.getElementById('modalGiftId').value = data.id;
document.getElementById('modalChild').value = data.child_name; document.getElementById('modalChild').value = data.child_name;
document.getElementById('modalOccasion').value = data.occasion; document.getElementById('modalOccasion').value = data.occasion;
document.getElementById('modalDesc').value = data.gift_description; document.getElementById('modalDesc').value = data.gift_description;
document.getElementById('modalAmount').value = data.amount; document.getElementById('modalAmount').value = data.amount;
document.getElementById('modalLink').value = data.product_link; document.getElementById('modalLink').value = data.product_link;
@@ -471,31 +545,30 @@ document.body.addEventListener('click', async function(e) {
let adultExists = false, payerExists = false; let adultExists = false, payerExists = false;
for(let i=0; i<adultSelect.options.length; i++) { if(adultSelect.options[i].value === data.adult_name) adultExists = true; } for(let i=0; i<adultSelect.options.length; i++) { if(adultSelect.options[i].value === data.adult_name) adultExists = true; }
for(let i=0; i<payerSelect.options.length; i++) { if(payerSelect.options[i].value === payerVal) payerExists = true; } for(let i=0; i<payerSelect.options.length; i++) { if(payerSelect.options[i].value === payerVal) payerExists = true; }
if (!adultExists) adultSelect.appendChild(new Option(data.adult_name, data.adult_name)); if (!adultExists) adultSelect.appendChild(new Option(data.adult_name, data.adult_name));
if (!payerExists) payerSelect.appendChild(new Option(payerVal, payerVal)); if (!payerExists) payerSelect.appendChild(new Option(payerVal, payerVal));
adultSelect.value = data.adult_name; adultSelect.value = data.adult_name;
payerSelect.value = payerVal; payerSelect.value = payerVal;
} }
document.getElementById('modalTitle').textContent = window.I18N && window.I18N['gift_modal_title_edit'] ? window.I18N['gift_modal_title_edit'] : 'Modifier le cadeau'; document.getElementById('modalTitle').textContent = window.I18N && window.I18N['gift_modal_title_edit'] ? window.I18N['gift_modal_title_edit'] : 'Modifier le cadeau';
if (modal) { modal.classList.add('open'); document.body.classList.add('no-scroll'); } if (modal) { modal.classList.add('open'); document.body.classList.add('no-scroll'); }
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;
const msg = window.I18N && window.I18N['gift_confirm_delete'] ? window.I18N['gift_confirm_delete'] : 'Supprimer ce cadeau ?'; const msg = window.I18N && window.I18N['gift_confirm_delete'] ? window.I18N['gift_confirm_delete'] : 'Supprimer ce cadeau ?';
if (!confirm(msg)) return; if (!confirm(msg)) return;
const fd = new FormData(); const fd = new FormData();
fd.append('action', 'delete'); fd.append('action', 'delete');
fd.append('gift_id', giftId); fd.append('gift_id', giftId);
try { try {
await fetch('/modules/gift-list/save-gift.php', { method: 'POST', body: fd }); await fetch('/modules/gift-list/save-gift.php', { method: 'POST', body: fd });
window.location.reload(); window.location.reload();
@@ -506,17 +579,13 @@ 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) => {
e.preventDefault(); e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]'); const btn = e.target.querySelector('button[type="submit"]');
const oldText = btn.innerText; const oldText = btn.innerText;
btn.innerText = '...'; btn.innerText = '...';
btn.disabled = true; btn.disabled = true;
try { try {
@@ -524,11 +593,382 @@ if (giftForm) {
window.location.reload(); window.location.reload();
} catch(err) { } catch(err) {
console.error(err); console.error(err);
btn.innerText = oldText; btn.innerText = oldText;
btn.disabled = false; btn.disabled = false;
} }
}); });
} }
// ==========================================
// 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'; ?>
+1212 -91
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = tr('mod_groceries_name') . ' — HouseHub';
$activePage = 'groceries';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/groceries/assets/groceries.css">
<div id="groceries-toasts" class="groceries-toast-container"></div>
<div class="groceries-layout">
<div class="groceries-hero">
<h1>🛒 <?= htmlspecialchars(tr('mod_groceries_name')) ?></h1>
<p><?= htmlspecialchars(tr('mod_groceries_desc')) ?></p>
<p id="groceries-subtitle" class="pf-muted-note" style="margin-top:0.5rem;font-size:0.85rem"></p>
</div>
<div class="groceries-add-card">
<div class="groceries-add-card-inner">
<div class="groceries-quick-add">
<input type="text" id="groceries-new" class="pf-input groceries-add-input" placeholder="<?= htmlspecialchars(tr('groceries_placeholder_add')) ?>" autocomplete="off" maxlength="500" aria-label="<?= htmlspecialchars(tr('groceries_placeholder_add')) ?>">
<button type="button" class="pf-btn groceries-add-submit" onclick="addFromInput()"><?= htmlspecialchars(tr('groceries_btn_add')) ?></button>
</div>
</div>
</div>
<div class="groceries-toolbar">
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn" onclick="uncheckAll()"><?= htmlspecialchars(tr('groceries_btn_uncheck_all')) ?></button>
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--danger" onclick="deletePicked()"><?= htmlspecialchars(tr('groceries_btn_delete_picked')) ?></button>
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--outline" onclick="clearWholeList()"><?= htmlspecialchars(tr('groceries_btn_clear_list')) ?></button>
</div>
<div id="groceries-lists"></div>
<div class="groceries-history-card" id="groceries-history-root" aria-live="polite"></div>
</div>
<script src="/modules/groceries/assets/groceries.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+25 -7
View File
@@ -45,7 +45,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<a href="/index.php" class="pf-logo">HouseHub</a> <a href="/index.php" class="pf-logo">HouseHub</a>
<?php if (isset($_SESSION['user'])): ?> <?php if (isset($_SESSION['user'])): ?>
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts']; ?> <?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','calendar_ios']; ?>
<nav class="pf-nav"> <nav class="pf-nav">
<a href="/index.php" class="pf-nav-link <?= $activePage === 'home' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_home') ?></a> <a href="/index.php" class="pf-nav-link <?= $activePage === 'home' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_home') ?></a>
<?php if (in_array('calendar', $mods)): ?><a href="/family-calendar.php" class="pf-nav-link <?= $activePage === 'family-calendar' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar') ?></a><?php endif; ?> <?php if (in_array('calendar', $mods)): ?><a href="/family-calendar.php" class="pf-nav-link <?= $activePage === 'family-calendar' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar') ?></a><?php endif; ?>
@@ -53,6 +53,12 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-nav-link <?= $activePage === 'holidays' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_holidays') ?></a><?php endif; ?> <?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-nav-link <?= $activePage === 'holidays' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_holidays') ?></a><?php endif; ?>
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-nav-link <?= $activePage === 'gift-list' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_gifts') ?></a><?php endif; ?> <?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-nav-link <?= $activePage === 'gift-list' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_gifts') ?></a><?php endif; ?>
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?> <?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('food', $mods)): ?><a href="/food.php" class="pf-nav-link <?= $activePage === 'food' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_food') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-nav-link <?= $activePage === 'printvault' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_printvault') ?></a><?php endif; ?>
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-nav-link <?= $activePage === 'planka' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_planka') ?></a><?php endif; ?>
</nav> </nav>
<?php endif; ?> <?php endif; ?>
@@ -98,6 +104,12 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-mobile-nav-link">🏖️ <?= tr('menu_holidays') ?></a><?php endif; ?> <?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-mobile-nav-link">🏖️ <?= tr('menu_holidays') ?></a><?php endif; ?>
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-mobile-nav-link">🎁 <?= tr('menu_gifts') ?></a><?php endif; ?> <?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-mobile-nav-link">🎁 <?= tr('menu_gifts') ?></a><?php endif; ?>
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?> <?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('food', $mods)): ?><a href="/food.php" class="pf-mobile-nav-link">🍳 <?= tr('menu_food') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-mobile-nav-link">🖨️ <?= tr('menu_printvault') ?></a><?php endif; ?>
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-mobile-nav-link">📋 <?= tr('menu_planka') ?></a><?php endif; ?>
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a> <a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
<?php if (!empty($_SESSION['user']['is_admin'])): ?> <?php if (!empty($_SESSION['user']['is_admin'])): ?>
<a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a> <a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a>
@@ -110,17 +122,23 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<main class="pf-main<?= ($mainClass ?? '') ? ' '.htmlspecialchars($mainClass) : '' ?>"> <main class="pf-main<?= ($mainClass ?? '') ? ' '.htmlspecialchars($mainClass) : '' ?>">
<script> <script>
// CSRF avant I18N : le parseur HTML coupe le script dès la séquence de fin de balise « script » (même dans un commentaire JS).
window.CSRF_TOKEN = "<?= htmlspecialchars(function_exists('csrf_token') ? csrf_token() : '', ENT_QUOTES, 'UTF-8') ?>";
/** /**
* Pont d'Internationalisation et Configuration * Pont d'Internationalisation et Configuration
*/ */
window.I18N = <?php echo json_encode($current_translations_array ?? []); ?>; 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) : '[]' ?>,
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>' // ID du user global connecté (pour savoir "qui" clique)
}; CURRENT_USER_ID: <?= isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : 'null' ?>,
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>',
ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>'
};
function tr(key) { function tr(key) {
return window.I18N[key] || key; return window.I18N[key] || key;
+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';
+74
View File
@@ -4,6 +4,12 @@ if (session_status() === PHP_SESSION_NONE) {
session_start(); session_start();
} }
/**
* Vérifie qu'un utilisateur est connecté.
* Si non, le redirige vers la page de login avec ?redirect=URL_DEMANDEE
*
* @param string|null $loginPage URL de la page de login (par défaut /login.php)
*/
/** /**
* Vérifie qu'un utilisateur est connecté. * Vérifie qu'un utilisateur est connecté.
* Si non, le redirige vers la page de login avec ?redirect=URL_DEMANDEE * Si non, le redirige vers la page de login avec ?redirect=URL_DEMANDEE
@@ -16,6 +22,47 @@ function require_login(?string $loginPage = '/login.php'): void
session_start(); session_start();
} }
// --- NOUVEAU : Auto-login via Cookie ---
if (empty($_SESSION['user']) && isset($_COOKIE['hh_remember'])) {
$cookieParts = explode(':', $_COOKIE['hh_remember']);
if (count($cookieParts) === 2) {
$cookieUserId = (int)$cookieParts[0];
$cookieToken = $cookieParts[1];
global $meta_pdo;
require_once __DIR__ . '/meta_db.php';
$stmt = $meta_pdo->prepare("
SELECT u.id, u.username, u.password_hash, u.display_name, u.family_id, u.is_admin, u.is_active, u.lang, u.remember_token,
f.db_name, f.is_active as family_active, f.enabled_modules
FROM users u
LEFT JOIN families f ON f.id = u.family_id
WHERE u.id = ?
");
$stmt->execute([$cookieUserId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
// On vérifie que le token correspond au hash stocké
if ($user && $user['remember_token'] && hash_equals($user['remember_token'], hash('sha256', $cookieToken))) {
if ($user['is_active'] && (!$user['family_id'] || $user['family_active'])) {
// Connexion silencieuse réussie, on peuple la session
$_SESSION['user'] = [
'id' => (int)$user['id'],
'username' => $user['username'],
'display_name' => $user['display_name'],
'family_id' => (int)$user['family_id'],
'is_admin' => (bool)$user['is_admin'],
];
$_SESSION['family_db'] = $user['db_name'];
$_SESSION['app_lang'] = $user['lang'] ?? 'fr';
$_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts","calendar_ios"]', true);
}
}
}
}
// ---------------------------------------
if (empty($_SESSION['user'])) { if (empty($_SESSION['user'])) {
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false)) { if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false)) {
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -32,3 +79,30 @@ function require_login(?string $loginPage = '/login.php'): void
exit; exit;
} }
} }
function csrf_token(): string
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(?string $token): bool
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['csrf_token']) || !$token) {
return false;
}
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);
}
-8
View File
@@ -1,8 +0,0 @@
<?php
// HouseHub Config - Version sans erreur
if (!defined('ID_ALEX')) {
define('ID_ALEX', 2);
define('ID_LAIA', 3);
define('CURRENCY', '€');
define('ZONE_SCOLAIRE', 'C');
}
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* Matériau de clé pour chiffrer les secrets (mot de passe d’app iCloud, etc.).
* Ordre de priorité :
* 1. Variable d’environnement APP_SECRET_KEY (recommandé en production)
* 2. Fichier data/.hh_app_secret (généré automatiquement au premier besoin si le dossier est inscriptible)
* 3. Dérivation stable depuis DB_PASS + DB_HOST (évite l’erreur si rien n’est configuré ; change si le mot de passe DB change)
*/
function hh_secret_key_material(): string
{
$fromEnv = trim((string) getenv('APP_SECRET_KEY'));
if ($fromEnv !== '') {
return $fromEnv;
}
$keyFile = dirname(__DIR__) . '/data/.hh_app_secret';
if (is_readable($keyFile)) {
$content = trim((string) file_get_contents($keyFile));
if ($content !== '' && strlen($content) >= 32) {
return $content;
}
}
$dir = dirname($keyFile);
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
if (is_dir($dir) && is_writable($dir)) {
try {
$material = bin2hex(random_bytes(32));
if (@file_put_contents($keyFile, $material, LOCK_EX) !== false) {
@chmod($keyFile, 0600);
return $material;
}
} catch (\Throwable $e) {
// continuer vers la dérivation
}
}
$dbPass = (string) (getenv('DB_PASS') ?: '');
$dbHost = (string) (getenv('DB_HOST') ?: '');
return 'hh-derived|' . $dbPass . '|' . $dbHost . '|HouseHub-calendar-ios-v1';
}
function hh_encryption_key(): string
{
return hash('sha256', hh_secret_key_material(), true);
}
function hh_encrypt_secret(string $plain): string
{
$key = hh_encryption_key();
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
if ($cipher === false) {
throw new RuntimeException('Chiffrement impossible');
}
return base64_encode($iv . $cipher);
}
function hh_decrypt_secret(string $encrypted): string
{
$key = hh_encryption_key();
$raw = base64_decode($encrypted, true);
if ($raw === false || strlen($raw) <= 16) {
throw new RuntimeException('Secret invalide');
}
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
$plain = openssl_decrypt($cipher, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
if ($plain === false) {
throw new RuntimeException('Déchiffrement impossible');
}
return $plain;
}
+42 -24
View File
@@ -1,41 +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 = '';
} }
// 2. Multi-tenant strict : cibler la DB de la famille courante
$db = 'househub_meta'; // Fallback par défaut
if (!empty($_SESSION['user']['family_id'])) {
$db = 'househub_f' . (int)$_SESSION['user']['family_id'];
}
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30,
];
try { try {
$pdo = new PDO( $pdo = new PDO($dsn, $user, $pass, $options);
"mysql:host=$host;dbname=$db;charset=utf8mb4",
$user, $pass, // Forcer la collation
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30,
]
);
$pdo->exec("SET collation_connection = utf8mb4_general_ci"); $pdo->exec("SET collation_connection = utf8mb4_general_ci");
$pdo->exec("SET collation_database = utf8mb4_general_ci");
$pdo->exec("SET collation_server = utf8mb4_general_ci");
} catch (\PDOException $e) { } 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()]));
} }
?> ?>
+4 -1
View File
@@ -26,4 +26,7 @@ if (!function_exists('tr')) {
function tr($key) { function tr($key) {
return $GLOBALS['translations'][$key] ?? $key; return $GLOBALS['translations'][$key] ?? $key;
} }
} }
// Export pour le client (header.php → window.I18N) : pachaConfirm, modules JS, etc.
$current_translations_array = $GLOBALS['translations'] ?? [];
+236
View File
@@ -0,0 +1,236 @@
<?php
/**
* Normalise le mot de passe d’application Apple (supprime les espaces, garde les tirets).
*/
function hh_normalize_apple_app_password(string $password): string
{
return preg_replace('/\s+/', '', trim($password));
}
function hh_caldav_url_is_icloud(string $url): bool
{
$h = strtolower((string) (parse_url(trim($url), PHP_URL_HOST) ?: ''));
return str_contains($h, 'icloud.com');
}
function hh_caldav_is_icloud_well_known_root(string $url): bool
{
$parts = parse_url(rtrim(trim($url), '/'));
if (empty($parts['scheme']) || empty($parts['host'])) {
return false;
}
$host = strtolower($parts['host']);
if ($host !== 'caldav.icloud.com') {
return false;
}
$path = $parts['path'] ?? '';
return $path === '' || $path === '/';
}
function hh_caldav_resolve_href(string $againstUrl, string $href): string
{
$href = trim($href);
if ($href === '') {
return $againstUrl;
}
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
return $href;
}
$parts = parse_url($againstUrl);
$scheme = $parts['scheme'] ?? 'https';
$host = $parts['host'] ?? '';
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
if ($href[0] === '/') {
return $scheme . '://' . $host . $port . $href;
}
$base = preg_replace('#/[^/]*$#', '/', $againstUrl);
return rtrim($base, '/') . '/' . ltrim($href, '/');
}
/**
* @return array{code:int, body:string}
*/
function hh_caldav_propfind(string $url, string $username, string $password, string $xml, string $depth = '0'): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 25,
CURLOPT_CUSTOMREQUEST => 'PROPFIND',
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $username . ':' . $password,
CURLOPT_HTTPHEADER => [
'Content-Type: application/xml; charset=utf-8',
'Depth: ' . $depth,
],
CURLOPT_POSTFIELDS => $xml,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 10,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false) {
return ['code' => 0, 'body' => ''];
}
return ['code' => $code, 'body' => $body];
}
function hh_caldav_first_href_by_element(string $xml, string $parentLocalName): ?string
{
$dom = new DOMDocument();
if (!@$dom->loadXML($xml)) {
return null;
}
$xpath = new DOMXPath($dom);
$nodes = $xpath->query("//*[local-name()='{$parentLocalName}']//*[local-name()='href']");
if ($nodes->length === 0) {
return null;
}
return trim($nodes->item(0)->textContent);
}
/**
* @return list<array{href:string, display:string}>
*/
function hh_caldav_parse_calendar_collections(string $xml): array
{
$dom = new DOMDocument();
if (!@$dom->loadXML($xml)) {
return [];
}
$xpath = new DOMXPath($dom);
$responses = $xpath->query("//*[local-name()='response']");
$out = [];
foreach ($responses as $resp) {
$hrefNodes = $xpath->query(".//*[local-name()='href']", $resp);
if ($hrefNodes->length === 0) {
continue;
}
$href = trim($hrefNodes->item(0)->textContent);
$cal = $xpath->query(".//*[local-name()='resourcetype']/*[local-name()='calendar']", $resp);
if ($cal->length === 0 || $href === '') {
continue;
}
$display = '';
$dn = $xpath->query(".//*[local-name()='displayname']", $resp);
if ($dn->length > 0) {
$display = trim($dn->item(0)->textContent);
}
$out[] = ['href' => $href, 'display' => $display];
}
return $out;
}
/**
* Liste les collections calendrier iCloud (URL complète avec / final + nom affiché).
*
* @return list<array{display:string, url:string}>
*
* @throws RuntimeException
*/
function hh_icloud_discover_calendar_entries(string $username, string $password): array
{
$password = hh_normalize_apple_app_password($password);
$root = 'https://caldav.icloud.com/';
$xml1 = '<?xml version="1.0" encoding="UTF-8"?>'
. '<propfind xmlns="DAV:">'
. '<prop><current-user-principal/></prop>'
. '</propfind>';
$r1 = hh_caldav_propfind($root, $username, $password, $xml1, '0');
if ($r1['code'] === 401 || $r1['code'] === 403) {
throw new RuntimeException('Identifiant Apple ou mot de passe d’app refusé (HTTP ' . $r1['code'] . ').');
}
if (!in_array($r1['code'], [200, 207], true)) {
throw new RuntimeException('CalDAV racine inaccessible (HTTP ' . $r1['code'] . ').');
}
$principalHref = hh_caldav_first_href_by_element($r1['body'], 'current-user-principal');
if (!$principalHref) {
throw new RuntimeException('Réponse CalDAV inattendue : principal introuvable.');
}
$principalUrl = hh_caldav_resolve_href($root, $principalHref);
$xml2 = '<?xml version="1.0" encoding="UTF-8"?>'
. '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
. '<prop><C:calendar-home-set/></prop>'
. '</propfind>';
$r2 = hh_caldav_propfind($principalUrl, $username, $password, $xml2, '0');
if (!in_array($r2['code'], [200, 207], true)) {
throw new RuntimeException('Lecture du principal CalDAV impossible (HTTP ' . $r2['code'] . ').');
}
$homeHref = hh_caldav_first_href_by_element($r2['body'], 'calendar-home-set');
if (!$homeHref) {
throw new RuntimeException('Aucun dossier de calendriers (calendar-home-set) trouvé.');
}
$homeUrl = hh_caldav_resolve_href($principalUrl, $homeHref);
$xml3 = '<?xml version="1.0" encoding="UTF-8"?>'
. '<propfind xmlns="DAV:">'
. '<prop><resourcetype/><displayname/></prop>'
. '</propfind>';
$r3 = hh_caldav_propfind($homeUrl, $username, $password, $xml3, '1');
if (!in_array($r3['code'], [200, 207], true)) {
throw new RuntimeException('Liste des calendriers impossible (HTTP ' . $r3['code'] . ').');
}
$raw = hh_caldav_parse_calendar_collections($r3['body']);
if ($raw === []) {
throw new RuntimeException('Aucun calendrier trouvé sur ce compte iCloud.');
}
$out = [];
foreach ($raw as $e) {
$url = rtrim(hh_caldav_resolve_href($homeUrl, $e['href']), '/') . '/';
$out[] = ['display' => $e['display'], 'url' => $url];
}
return $out;
}
/**
* Depuis la racine iCloud (https://caldav.icloud.com), découvre l’URL d’un calendrier (par défaut « Calendar » / « Calendrier », sinon le premier).
*
* @throws RuntimeException
*/
function hh_icloud_discover_default_calendar_url(string $username, string $password): string
{
$entries = hh_icloud_discover_calendar_entries($username, $password);
$preferred = ['calendar', 'calendrier', 'home', 'par défaut', 'default'];
foreach ($entries as $e) {
$lower = mb_strtolower($e['display']);
foreach ($preferred as $p) {
if ($lower !== '' && str_contains($lower, $p)) {
return $e['url'];
}
}
}
return $entries[0]['url'];
}
/**
* Vérifie qu’une URL de collection calendrier répond en CalDAV (PROPFIND).
*/
function hh_caldav_test_calendar_collection(string $username, string $password, string $calendarUrl): int
{
$password = hh_normalize_apple_app_password($password);
$xml = '<?xml version="1.0" encoding="UTF-8"?>'
. '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
. '<prop><resourcetype/><displayname/><C:supported-calendar-component-set/></prop>'
. '</propfind>';
$r = hh_caldav_propfind(rtrim($calendarUrl, '/') . '/', $username, $password, $xml, '0');
return $r['code'];
}
/**
* Si URL = racine iCloud bien connue, découvre et retourne l’URL d’un calendrier ; sinon retourne l’URL telle quelle (trim).
*
* @throws RuntimeException
*/
function hh_icloud_resolve_calendar_url_if_needed(string $username, string $password, string $calendarUrl): string
{
$calendarUrl = trim($calendarUrl);
if (hh_caldav_is_icloud_well_known_root($calendarUrl)) {
return hh_icloud_discover_default_calendar_url($username, $password);
}
return $calendarUrl;
}
+570 -145
View File
@@ -1,27 +1,23 @@
<?php <?php
// Dictionnaire Catalan // Dictionnaire Catalan - HouseHub
return [ return [
// ========================================== // ==========================================
// TEST AUTO // 1. GLOBAL, NAVIGATION & ACTIONS
// ==========================================
'test_running' => 'Proves en curs...',
'test_passed' => 'Prova superada',
'test_failed' => 'Prova fallida',
'tests_title' => 'Laboratori de Proves',
'tests_run_budget' => 'Provar el Pressupost',
'tests_run_all' => 'Iniciar totes les proves',
'tests_copy_report' => 'Copiar l\'informe',
'tests_waiting' => 'Esperant l\'inici...',
'tests_report_copied' => 'Informe copiat al porta-retalls!',
'tests_live_preview' => 'Previsualització en viu',
// ==========================================
// GLOBAL & NAVIGATION
// ========================================== // ==========================================
'menu_home' => 'Inici', 'menu_home' => 'Inici',
'menu_calendar' => 'Calendari', 'menu_calendar' => 'Calendari',
'menu_budget' => 'Pressupost', 'menu_budget' => 'Pressupost',
'menu_holidays' => 'Viatges', 'menu_holidays' => 'Viatges',
'menu_gifts' => 'Regals', 'menu_gifts' => 'Regals',
'menu_garage' => 'Garatge',
'menu_memo' => 'Notes',
'menu_todo' => 'Todo',
'menu_liste' => 'Llistes',
'menu_groceries' => 'Compra',
'menu_calendar_ios' => 'Calendari iOS',
'menu_printvault' => 'PrintVault',
'menu_planka' => 'Planka',
'nav_home' => 'Inici', 'nav_home' => 'Inici',
'nav_calendar' => 'Calendari', 'nav_calendar' => 'Calendari',
'nav_budget' => 'Pressupost', 'nav_budget' => 'Pressupost',
@@ -29,7 +25,6 @@ return [
'nav_gifts' => 'Regals', 'nav_gifts' => 'Regals',
'aria_menu' => 'Menú mòbil', 'aria_menu' => 'Menú mòbil',
// Boutons et actions globales
'actions' => 'Accions', 'actions' => 'Accions',
'edit' => 'Modificar', 'edit' => 'Modificar',
'delete' => 'Suprimir', 'delete' => 'Suprimir',
@@ -39,28 +34,33 @@ return [
'btn_cancel' => 'Cancel·lar', 'btn_cancel' => 'Cancel·lar',
'btn_delete' => 'Eliminar', 'btn_delete' => 'Eliminar',
'btn_close' => 'Tancar', 'btn_close' => 'Tancar',
'btn_add' => 'Afegir',
'btn_back' => '◀ Tornar', 'btn_back' => '◀ Tornar',
'btn_edit_bases' => '⚙️ Modificar les bases', 'btn_edit_bases' => '⚙️ Modificar les bases',
'btn_edit' => 'Modificar', 'btn_edit' => 'Modificar',
'error_occured' => 'S\'ha produït un error.',
// Mots génériques & Erreurs
'error' => 'Error.', 'error' => 'Error.',
'error_occured' => 'S\'ha produït un error.',
'error_network' => 'Error de xarxa',
'error_generic' => 'S\'ha produït un error',
'confirm_delete' => 'Estàs segur que vols suprimir aquest element?', 'confirm_delete' => 'Estàs segur que vols suprimir aquest element?',
'loading' => 'Carregant...',
'date' => 'Data',
'settings' => 'Configuració',
// Mois & Jours // Mesos & Dies
'month_01' => 'Gener', 'month_02' => 'Febrer', 'month_03' => 'Març', 'month_01' => 'Gener', 'month_02' => 'Febrer', 'month_03' => 'Març', 'month_04' => 'Abril', 'month_05' => 'Maig', 'month_06' => 'Juny',
'month_04' => 'Abril', 'month_05' => 'Maig', 'month_06' => 'Juny', 'month_07' => 'Juliol','month_08' => 'Agost', 'month_09' => 'Setembre', 'month_10' => 'Octubre','month_11' => 'Novembre','month_12' => 'Desembre',
'month_07' => 'Juliol','month_08' => 'Agost', 'month_09' => 'Setembre', 'month_jan' => 'Gener', 'month_feb' => 'Febrer', 'month_mar' => 'Març', 'month_apr' => 'Abril', 'month_may' => 'Maig', 'month_jun' => 'Juny',
'month_10' => 'Octubre','month_11' => 'Novembre','month_12' => 'Desembre', 'month_jul' => 'Juliol', 'month_aug' => 'Agost', 'month_sep' => 'Setembre', 'month_oct' => 'Octubre', 'month_nov' => 'Novembre', 'month_dec' => 'Desembre',
'day_mon' => 'Dl', 'day_tue' => 'Dt', 'day_wed' => 'Dc', 'day_mon' => 'Dl', 'day_tue' => 'Dt', 'day_wed' => 'Dc', 'day_thu' => 'Dj', 'day_fri' => 'Dv', 'day_sat' => 'Ds', 'day_sun' => 'Dg',
'day_thu' => 'Dj', 'day_fri' => 'Dv', 'day_sat' => 'Ds', 'day_sun' => 'Dg',
// ========================================== // ==========================================
// AUTHENTIFICATION / LOGIN // 2. AUTHENTICATION / LOGIN
// ========================================== // ==========================================
'login_title' => 'Connexió - HouseHub', 'login_title' => 'Connexió - HouseHub',
'login_header' => 'Connexió', 'login_header' => 'Connexió',
'login_subtitle' => 'Si us plau, identifiqueu-vos per continuar',
'label_username' => "Nom d'usuari", 'label_username' => "Nom d'usuari",
'label_password' => 'Contrasenya', 'label_password' => 'Contrasenya',
'placeholder_username' => 'ex: pacha', 'placeholder_username' => 'ex: pacha',
@@ -68,18 +68,19 @@ return [
'btn_login_submit' => 'Connectar-se', 'btn_login_submit' => 'Connectar-se',
'logout' => 'Tancar sessió', 'logout' => 'Tancar sessió',
'btn_logout' => 'Tancar sessió', 'btn_logout' => 'Tancar sessió',
'login_remember_me' => "Recorda'm",
'error_missing_fields' => 'Camps obligatoris.', 'error_missing_fields' => 'Camps obligatoris.',
'error_invalid_credentials' => 'Identificadors incorrectes.', 'error_invalid_credentials' => 'Identificadors incorrectes.',
'login_subtitle' => 'Si us plau, identifiqueu-vos per continuar',
// ========================================== // ==========================================
// ACCUEIL / INDEX // 3. HOME & DASHBOARD
// ========================================== // ==========================================
'home_title' => 'HouseHub - Inici', 'home_title' => 'HouseHub - Inici',
'home_welcome' => 'Benvinguts a HouseHub', 'home_welcome' => 'Benvinguts a HouseHub',
'home_subtitle' => "Centre de control de l'organització familiar.", 'home_subtitle' => "Centre de control de l'organització familiar.",
'home_logged_as' => 'Connectat com a', 'home_logged_as' => 'Connectat com a',
'home_modules_title' => 'Mòduls', 'home_modules_title' => 'Mòduls',
'mod_calendar_name' => 'Calendari Familiar', 'mod_calendar_name' => 'Calendari Familiar',
'mod_calendar_desc' => "Gestioneu les vacances, les modalitats de cura i l'organització setmanal.", 'mod_calendar_desc' => "Gestioneu les vacances, les modalitats de cura i l'organització setmanal.",
'mod_holidays_name' => 'Vacances', 'mod_holidays_name' => 'Vacances',
@@ -90,19 +91,153 @@ return [
'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.', 'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.',
'mod_garage_name' => 'Garatge', 'mod_garage_name' => 'Garatge',
'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.', 'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Els vostres memos, receptes, instruccions i informació diversa amb Markdown.',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Gestioneu les vostres tasques, llistes de la compra i recordatoris familiars.',
'mod_liste_name' => 'Llistes',
'mod_liste_desc' => 'Llistes compartides — compres, bricolatge, farmàcia… Tantes llistes com convingui.',
'mod_groceries_name' => 'Compra',
'mod_groceries_desc' => 'Llista compartida: afegiu productes i marqueu-los quan siguin al carretó.',
'mod_calendar_ios_name' => 'Calendari iOS',
'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).',
'mod_printvault_name'=> 'PrintVault',
'mod_printvault_desc'=> 'Gestioneu els vostres fitxers d\'impressió 3D — STL, 3MF, GCode.',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Gestioneu els vostres projectes en mode Kanban amb Planka.',
'cta_open' => 'Obrir', 'cta_open' => 'Obrir',
'cta_explore' => 'Explorar', 'cta_explore' => 'Explorar',
'cta_view_lists' => 'Veure les llistes', 'cta_view_lists' => 'Desembolicar',
'cta_manage' => 'Gestionar', 'cta_manage' => 'Gestionar',
'cta_service' => 'Revisar',
'cta_jot' => 'Apuntar',
'cta_check' => 'Marcar',
'cta_cart' => 'Anar a comprar',
'cta_sync' => 'Sincronitzar',
'cta_print' => 'Imprimir',
// ========================================== // ==========================================
// FAMILY CALENDAR // 4. SETTINGS
// ==========================================
'set_title_page' => '⚙️ Configuració',
'set_title_lang' => '🌐 Idioma',
'set_title_modules' => '🧩 Mòduls Actius',
'set_desc_modules' => 'Tria els mòduls visibles a la navegació (compartits amb tots els membres de l\'espai).',
'set_title_ios' => '📱 Integració de Calendari iOS (CalDAV)',
'set_desc_ios' => 'Configura aquí el teu calendari iCloud per sincronitzar els esdeveniments creats a HouseHub.',
'set_label_apple_id' => 'ID d\'Apple (correu iCloud)',
'set_label_apple_pwd' => 'Contrasenya d\'aplicació d\'Apple',
'set_label_apple_url' => 'URL del calendari CalDAV',
'set_btn_test_ios' => 'Prova la connexió',
'set_btn_disconnect_ios' => 'Desconnecta',
'set_title_bg' => '🖼️ Imatge d\'Inici',
'set_desc_bg' => 'Foto de fons mostrada a la pàgina d\'inici (compartida amb tots els membres).',
'set_badge_custom_bg' => 'Imatge personalitzada',
'set_label_new_bg' => 'Nova imatge (jpg, png, webp — màx 10 MB)',
'set_btn_reset_bg' => '🔄 Restableix la imatge per defecte',
'set_confirm_reset_bg' => 'Tornar a la imatge per defecte?',
'set_title_profile' => '👤 El meu Perfil',
'set_label_username' => 'Nom d\'usuari',
'set_label_display_name' => 'Nom / Àlies mostrat',
'set_label_profile_color' => 'Color temàtic del meu perfil',
'set_desc_profile_color' => 'Aquest color s\'utilitzarà per a les teves graelles de pressupost, calendaris i indicadors personals.',
'set_title_pwd' => '🔒 Canviar Contrasenya',
'set_label_cur_pwd' => 'Contrasenya actual',
'set_label_new_pwd' => 'Nova contrasenya',
'set_label_confirm_pwd' => 'Confirma',
'set_btn_edit' => 'Modifica',
'set_title_family' => '🏠 El meu Espai Familiar',
'set_label_family_name' => 'Nom de l\'espai',
'set_desc_invite' => 'Codi d\'invitació — comparteix-lo per convidar algú',
'set_title_copy' => 'Fes clic per copiar',
'set_msg_copied' => '✓ Copiat!',
'set_btn_new_code' => '🔄 Nou codi',
'set_confirm_new_code' => 'Regenerar codi? L\'antic serà invalidat.',
'set_desc_register_link' => 'Enllaç de registre:',
'set_title_members' => 'Membres',
'set_badge_admin' => 'Admin',
'set_title_attached' => '🧸 Perfils Adjunts',
'set_desc_attached' => 'Membres gestionats per la llar sense accés directe.',
'set_empty_attached' => 'Cap perfil adjunt.',
'set_link_admin' => '→ Tauler d\'Administració',
'set_kids_title' => 'Nens de la llar',
'set_kids_desc' => 'Gestioneu la llista de nens per associar-los a l\'estalvi, als regals, etc.',
'set_add_kid_placeholder' => 'Nom del nen',
'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',
// Labels Profiles
'label_firstname' => 'Nom',
'placeholder_firstname_ex' => 'Ex: Pol, Carole...',
'label_role' => 'Rol',
'role_child' => '👶 Nen/a',
'role_child_icon' => '👶 Nen/a',
'role_helper' => '💼 Ajudant',
'role_helper_icon' => '💼 Ajudant',
'role_relative' => '👵 Familiar',
'role_relative_icon' => '👵 Familiar',
'role_unknown' => 'Desconegut',
'label_color' => 'Color',
'change_color' => 'Canviar el color',
'confirm_delete_profile' => 'Voleu eliminar definitivament aquest perfil?',
// Settings Messages
'error_csrf' => 'Sessió invàlida (CSRF). Si us plau, recarrega la pàgina.',
'set_err_min_module' => 'Has de mantenir almenys un mòdul actiu.',
'set_success_modules' => 'Mòduls actualitzats amb èxit.',
'set_success_lang' => 'Idioma actualitzat.',
'set_err_empty_name' => 'El nom no pot estar buit.',
'set_success_profile' => 'Perfil actualitzat amb èxit.',
'set_err_wrong_pwd' => 'Contrasenya actual incorrecta.',
'set_err_pwd_length' => 'La nova contrasenya ha de tenir almenys 6 caràcters.',
'set_err_pwd_match' => 'Les contrasenyes no coincideixen.',
'set_success_pwd' => 'Contrasenya canviada amb èxit.',
'set_success_family' => 'Nom de l\'espai actualitzat.',
'set_success_invite' => 'Nou codi d\'invitació generat.',
'set_err_bg_format' => 'Format no compatible (jpg, png, webp).',
'set_success_bg' => 'Imatge d\'inici actualitzada.',
'set_err_upload' => 'Error durant la càrrega.',
'set_success_bg_reset' => 'Imatge d\'inici restablerta.',
'set_err_db_unavailable' => 'Base de dades familiar no disponible.',
'set_err_ios_missing' => 'Si us plau, proporciona un ID d\'iCloud, contrasenya d\'aplicació i URL CalDAV.',
'set_success_ios' => 'Connexió de calendari iOS desada.',
'set_success_ios_auto' => ' URL del calendari detectada automàticament.',
'set_err_ios_save' => 'No es pot desar la connexió iOS: ',
'set_err_ios_none' => 'Cap connexió iOS configurada.',
'set_success_ios_test' => 'Connexió iCloud CalDAV vàlida (HTTP %s).',
'set_err_ios_test' => 'La prova de connexió ha fallat (HTTP %s). Revisa les teves credencials.',
'set_err_ios_test_fail' => 'Prova de connexió no disponible: ',
'set_success_ios_del' => 'Connexió iOS eliminada.',
'set_success_profile_add' => 'Perfil de %s afegit amb èxit.',
'set_err_name_required' => 'El nom és obligatori.',
'set_success_profile_del' => 'Perfil suprimit.',
'set_success_color' => 'Color actualitzat!',
'set_success_color_profile' => 'Color del perfil actualitzat.',
'set_success_color_update' => 'Color actualitzat!',
'color_updated' => 'Color actualitzat!',
'settings_profile_added' => 'Perfil de %s afegit amb èxit.',
'settings_err_name_required' => 'El nom és obligatori.',
'settings_attached_profiles_title' => 'Perfils adjunts',
'settings_attached_profiles_desc' => 'Membres de la llar sense accés directe.',
'settings_no_attached_profiles' => 'Cap perfil adjunt.',
'placeholder_apple_id' => 'nom@icloud.com',
'placeholder_apple_pwd' => 'xxxx-xxxx-xxxx-xxxx',
// ==========================================
// 5. FAMILY CALENDAR
// ========================================== // ==========================================
'fc_page_title' => 'HouseHub - Calendari', 'fc_page_title' => 'HouseHub - Calendari',
'fc_main_header' => 'Calendari Familiar', 'fc_main_header' => 'Calendari Familiar',
'fc_btn_correct_balances' => 'Corregir els saldos', 'fc_btn_correct_balances' => 'Corregir els saldos',
'fc_btn_school_holidays' => 'Vacances escolars', 'fc_btn_school_holidays' => 'Vacances escolars',
'fc_modal_holidays_title' => 'Vacances Escolars (Zona C)', 'fc_modal_holidays_title' => 'Vacances Escolars',
'fc_modal_snap_title' => 'Ajustar un saldo', 'fc_modal_snap_title' => 'Ajustar un saldo',
'fc_col_period' => 'Període', 'fc_col_period' => 'Període',
'fc_col_from' => 'Des del', 'fc_col_from' => 'Des del',
@@ -121,6 +256,7 @@ return [
'fc_weekly_planning' => 'Planificació setmanal', 'fc_weekly_planning' => 'Planificació setmanal',
'fc_col_month' => 'Mes', 'fc_col_month' => 'Mes',
'fc_col_week_short' => 'Set.', 'fc_col_week_short' => 'Set.',
'fc_col_week' => 'Setmana',
'fc_col_av' => 'Av.', 'fc_col_av' => 'Av.',
'fc_col_use' => 'Use', 'fc_col_use' => 'Use',
'fc_legend_title' => 'Llegenda', 'fc_legend_title' => 'Llegenda',
@@ -131,14 +267,98 @@ return [
'fc_unit_days' => 'dies', 'fc_unit_days' => 'dies',
'fc_menu_carole' => 'Permisos Carole', 'fc_menu_carole' => 'Permisos Carole',
'fc_clear' => 'Esborrar', 'fc_clear' => 'Esborrar',
'fc_menu_kids_leaves' => 'Permisos nens', 'fc_menu_kids_leaves' => 'Permisos pares',
'fc_err_save_snap' => 'Error en desar el correctiu.', 'fc_err_save_snap' => 'Error en desar el correctiu.',
'fc_err_fetch_gov' => 'Error en la connexió amb l\'API governamental.', 'fc_err_fetch_gov' => 'Error en la connexió amb l\'API governamental.',
'fc_err_no_data_gov' => 'No s\'han trobat dades a l\'API del govern per a aquest any.', 'fc_err_no_data_gov' => 'No s\'han trobat dades a l\'API del govern per a aquest any.',
'fc_err_action' => 'Error en l\'acció: ', 'fc_err_action' => 'Error en l\'acció: ',
'fc_err_select_leave' => 'Si us plau, selecciona un tipus de permís.',
'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_role_helper' => 'Ajudant',
'fc_role_unknown' => 'Desconegut',
'fc_care_modes_title' => 'Modes de Cura Predeterminats',
'fc_care_modes_desc' => 'Quins modes de cura s\'apliquen a aquest nen/a?',
'fc_no_care_mode' => 'No hi ha modes de cura configurats a la llar.',
'fc_matrix_title' => 'Matriu de Drets de Permís',
'fc_matrix_desc' => 'Declara els codis de comptadors i les dates de renovació.',
'fc_matrix_code' => 'Codi',
'fc_matrix_date' => 'Data de Reset',
'fc_matrix_empty' => 'No hi ha comptadors definits.',
'fc_matrix_saved' => 'Matriu de drets actualitzada! 💼',
'fc_child_saved' => 'Desat!',
'btn_add_counter' => '➕ Afegir',
'btn_save_rights' => 'Guardar',
'fc_modal_settings_title' => 'Configuració del Calendari',
'fc_zone_label' => 'Zona escolar',
'fc_zone_a' => 'Zona A',
'fc_zone_b' => 'Zona B',
'fc_zone_c' => 'Zona C',
'fc_zone_other' => 'Altra / Fora de França',
'fc_zone_desc' => 'Permet mostrar correctament les vacances escolars.',
'fc_care_modes_label' => 'Modalitats de cura',
'fc_add_mode_placeholder' => 'Ex: Mainadera, Llar d\'infants...',
'fc_empty_kids_msg' => 'Actualment no hi ha cap infant configurat a la teva família.',
'fc_link_settings' => 'Anar a la configuració general',
'fc_settings_updated' => 'Configuració actualitzada correctament!',
'fc_first_day' => 'Primer dia de la setmana',
'fc_helper_recap' => 'Resum Interventor',
'fc_no_helper_leaves' => 'Cap permís sol·licitat aquest mes.',
'fc_col_date' => 'Data',
'fc_col_type' => 'Tipus',
'fc_col_duration' => 'Durada',
'fc_full_year' => 'Any sencer',
'fc_custom_period' => 'Període específic...',
'fc_worked_days' => 'Treballats (Est.)',
'fc_tab_display' => '🖥️ Visualització',
'fc_display_title' => 'Ajustos de visualització',
'fc_display_desc' => 'Personalitza l\'aparença i el comportament predeterminat del teu calendari.',
'fc_default_view' => 'Vista per defecte',
'fc_working_hours' => 'Horari de cura (Per defecte)',
'fc_working_hours_desc' => 'Exemple: 08:30-18:00',
'fc_error' => 'Error: ',
'fc_leave_catalog_title' => 'Catàleg de permisos',
'fc_leave_catalog_desc' => 'Defineix els tipus de permisos possibles per a tota la família.',
'fc_add_leave_type' => 'Afegir un tipus de permís',
'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ó',
'fc_leave_obsolete' => 'Obsolet',
'fc_leave_code_placeholder' => 'Codi...',
'fc_leave_method_fixed' => 'Fix',
'fc_leave_method_accumulated'=> 'Acumulat',
'fc_date_format_placeholder'=> 'DD/MM',
'fc_date_format_title' => 'Format DD/MM',
'fc_table_code' => 'Codi',
'fc_table_method' => 'Mètode',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renov.',
'fc_label_carry_over' => 'Inclou dies reportats (Caducables) ⚠️',
'fc_carry_over_note' => 'Part del saldo global que caducarà a la data límit.',
'fc_carry_max' => 'Màx Reportable',
'fc_carry_deadline' => 'Mes de Caducitat',
'fc_deadline_none' => 'Cap',
'fc_allowance_help' => 'Fix: Quota anual. Gradual: Afegit per mes.',
'fc_carry_max_short' => 'Report màx',
'fc_edit_leave' => '✏️ Modificar:',
'fc_not_modifiable' => 'No modificable',
'fc_add_leave' => '+ Afegir',
'fc_err_code_label' => 'El Codi i l\'Etiqueta són obligatoris.',
'fc_catalog_updated' => 'Catàleg actualitzat!',
'fc_balance_corrected' => 'Saldo corregit!',
'fc_no_leave_configured' => 'Cap permís configurat.',
'fc_confirm_del_catalog' => 'Voleu eliminar definitivament el permís %s del catàleg? Això també el retirarà dels membres que l\'utilitzen.',
'fc_confirm_del_member_leave'=> 'Estàs segur que vols retirar aquest permís per a aquest membre?',
// Llegendes
'leg_presence' => 'Presència Pep', 'leg_presence' => 'Presència Pep',
'leg_school_holidays' => 'Vacances Escolars', 'leg_school_holidays' => 'Vacances Escolars',
'leg_public_holiday' => 'Festiu', 'leg_public_holiday' => 'Festiu',
@@ -146,8 +366,10 @@ return [
'leg_extra_off' => 'Extra Off', 'leg_extra_off' => 'Extra Off',
'leg_centre' => 'Casal', 'leg_centre' => 'Casal',
'leg_avis' => 'Avís', 'leg_avis' => 'Avís',
'leg_pep_sick' => 'Pep Malalt', 'fc_menu_sick' => 'Malalt',
'care_nounou' => 'Kangur',
'care_centre' => 'Casal',
'care_avis' => 'Avis',
'vac_toussaint' => 'Vacances de Tots Sants', 'vac_toussaint' => 'Vacances de Tots Sants',
'vac_noel' => 'Vacances de Nadal', 'vac_noel' => 'Vacances de Nadal',
'vac_hiver' => "Vacances d'Hivern", 'vac_hiver' => "Vacances d'Hivern",
@@ -156,12 +378,11 @@ return [
'vac_ete' => "Vacances d'Estiu", 'vac_ete' => "Vacances d'Estiu",
'btn_off' => 'Off', 'btn_off' => 'Off',
'btn_extra' => 'Extra', 'btn_extra' => 'Extra',
'fc_alert_burn_days' => '⚠️ Risc de pèrdua : %s d a gastar abans del final del cicle',
'fc_alert_burn_days' => '⚠️ Risc de pèrdua : %s d a gastar abans del %s', 'fc_alert_burn_jra' => '⚠️ JRA: Només 2d es poden reportar després de febrer!',
'fc_alert_burn_jra' => '⚠️ JRA: Només 2d es poden reportar després de febrer!',
// ========================================== // ==========================================
// HOLIDAYS (VOYAGES) // 6. HOLIDAYS (VOYAGES)
// ========================================== // ==========================================
'hdl_title_list' => 'HouseHub - Les meves vacances', 'hdl_title_list' => 'HouseHub - Les meves vacances',
'hdl_title_detail' => 'HouseHub - Detall del viatge', 'hdl_title_detail' => 'HouseHub - Detall del viatge',
@@ -236,24 +457,21 @@ return [
'hdl_to_place' => 'Per situar', 'hdl_to_place' => 'Per situar',
'hdl_from' => 'Des del', 'hdl_from' => 'Des del',
'hdl_to' => 'Fins al', 'hdl_to' => 'Fins al',
'hdl_btn_add_step' => 'Afegir una etapa', 'weather_forecast' => 'Previsions meteorològiques',
'hdl_quick_edit_title' => 'Modificació ràpida', 'weather_not_available' => 'Temps no disponible',
'hdl_paid' => 'Pagat', 'temp_max' => 'Temp. Màxima',
'weather_forecast' => 'Previsions meteorològiques', 'weather_loading' => 'Carregant el temps...',
'weather_not_available' => 'Temps no disponible', 'weather_error' => 'Temps no disponible',
'temp_max' => 'Temp. Màxima', 'weather_sunny' => 'Assolellat',
'weather_loading' => 'Carregant el temps...', 'weather_cloudy' => 'Ennuvolat',
'weather_error' => 'Temps no disponible', 'weather_rainy' => 'Plujós',
'weather_sunny' => 'Assolellat', 'weather_snowy' => 'Nevat',
'weather_cloudy' => 'Ennuvolat', 'weather_historical' => 'Basat en l\'històric (estimació)',
'weather_rainy' => 'Plujós', 'err_invalid_holiday_id' => 'ID de viatge no vàlid.',
'weather_snowy' => 'Nevat', 'hdl_default_exp_name' => 'Despesa',
'weather_historical' => 'Basat en l\'històric (estimació)', 'hdl_select_beneficiary' => '-- Seleccionar --',
'err_invalid_holiday_id' => 'ID de viatge no vàlid.',
'hdl_default_exp_name' => 'Despesa',
'hdl_select_beneficiary' => '-- Seleccionar --',
// Anciennes clés Holidays non préfixées (conservades) // Anciennes clés (conservades)
'outbound_trip' => 'Anada', 'outbound_trip' => 'Anada',
'return_trip' => 'Tornada', 'return_trip' => 'Tornada',
'period' => 'Període', 'period' => 'Període',
@@ -306,7 +524,7 @@ return [
'to_place' => '📥 A col·locar', 'to_place' => '📥 A col·locar',
// ========================================== // ==========================================
// BUDGET (GLOBAL & CATEGORIES) // 7. BUDGET (GLOBAL, SUIVI, PREV, EPARGNE, PROVISIONS)
// ========================================== // ==========================================
'budget_page_title' => 'HouseHub - Pressupost', 'budget_page_title' => 'HouseHub - Pressupost',
'budget_main_header' => 'Gestió del Pressupost', 'budget_main_header' => 'Gestió del Pressupost',
@@ -314,9 +532,16 @@ return [
'budget_tab_prev' => 'Pressupost 2026', 'budget_tab_prev' => 'Pressupost 2026',
'budget_tab_savings' => 'Estalvis', 'budget_tab_savings' => 'Estalvis',
'budget_tab_recap' => 'Resum', 'budget_tab_recap' => 'Resum',
'budget_tab_provisions' => 'Provisions',
'budget_tab_kids' => 'Nens',
'budget_err_file_not_found' => 'Vaja! Fitxer no trobat.', 'budget_err_file_not_found' => 'Vaja! Fitxer no trobat.',
'budget_err_file_detail' => 'Comproveu que el fitxer views/%s.php existeix.', 'budget_err_file_detail' => 'Comproveu que el fitxer views/%s.php existeix.',
'budget_err_not_authorized' => 'Pàgina no autoritzada.', 'budget_err_not_authorized' => 'Pàgina no autoritzada.',
'bud_err_tech' => 'S\'ha produït un error tècnic.',
'bud_err_server' => 'Error del servidor: ',
'bud_err_delete' => 'Error en suprimir.',
// Categories
'cat_income' => 'Ingressos', 'cat_income' => 'Ingressos',
'cat_fmcg' => 'Compres (FMCG)', 'cat_fmcg' => 'Compres (FMCG)',
'cat_fuel' => 'Benzina', 'cat_fuel' => 'Benzina',
@@ -325,7 +550,104 @@ return [
'cat_others' => 'Altres / Imprevistos', 'cat_others' => 'Altres / Imprevistos',
'cat_savings' => 'Estalvis', 'cat_savings' => 'Estalvis',
// --- BUDGET : SUIVI --- // Settings
'budget_settings_title' => 'Configuració del Pressupost',
'bs_tab_accounts' => '💳 Comptes',
'bs_tab_categories' => '🏷️ Categories',
'bs_tab_rules' => '🤖 Regles d\'importació',
'bs_tab_salaries' => '💼 Salaris',
'bs_tab_csv' => '📥 Importació CSV',
'bs_empty_accounts' => 'Cap compte configurat.',
'bs_type_checking' => 'Corrent',
'bs_type_savings' => 'Estalvi',
'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!',
'bud_settings_dyn_title' => 'Estimacions Dinàmiques (Nens)',
'bud_dyn_nanny_fixed' => 'Sou fix Cangur (€)',
'bud_dyn_nanny_daily' => 'Despeses diàries de manteniment (€)',
'bud_dyn_nanny_aid' => 'Ajuda del govern (€)',
'bud_dyn_nanny_cesu_avg' => 'Mitjana CESU pagada (€)',
'bud_dyn_school_meal' => 'Preu del menjador (€)',
'bud_dyn_school_aftercare' => 'Preu acollida tarda (€)',
'bud_dyn_school_fullday' => 'Preu acollida dia sencer (Dimecres/Vacances) (€)',
'bud_dyn_pattern_title' => 'Setmana escolar tipus',
'bud_audit_month' => 'Mes analitzat: %02d/%d',
'bud_audit_nanny_abs' => 'Absències %s deduïdes : %dd',
'bud_audit_child' => '👦 %s :',
'bud_audit_school_fee' => 'Menjador (%dd) + Acollida (%dd) + Casal (%dd) = %.2f€',
'bud_audit_sick' => 'Dies de malaltia deduïts : %dd',
'bud_audit_nanny_full' => '💼 %s : Fix (%.2f€) + Despeses (%dd x %.2f€ = %.2f€) - Ajut (%.2f€) - CESU (%.2f€) = %.2f€',
// CSV
'bs_csv_title' => 'Format de la importació CSV',
'bs_csv_desc' => 'Configureu aquí les columnes que corresponen a l\'exportació del vostre banc.',
'bs_csv_delimiter' => 'Separador',
'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_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.',
// Onboarding
'bs_onb_title' => 'Benvinguts! Configurem el teu pressupost:',
'bs_onb_desc' => 'Completa aquests passos perquè l\'aplicació pugui calcular el teu pressupost disponible:',
'bs_onb_cat_link' => 'Categories',
'bs_onb_cat_desc' => 'Crea almenys una categoria de despeses.',
'bs_onb_acc_link' => 'Comptes Bancaris',
'bs_onb_acc_desc' => 'Afegeix almenys un compte corrent o d\'estalvi.',
'bs_onb_sal_link' => 'Sous',
'bs_onb_sal_desc' => 'Introdueix els ingressos de l\'any en curs.',
'bs_onb_items_link' => 'Línies de Pressupost',
'bs_onb_items_desc' => 'Ves a la pestanya Resum per llistar les teves despeses fixes (Lloguer, Assegurances...).',
'bs_rule_search_ph' => '🔍 Cercar una paraula clau (ex: AUCHAN)...',
'error_loading_settings' => 'Error en carregar la configuració',
'bud_err_delete_system_cat' => 'Impossible de suprimir una categoria de sistema essencial.',
// Budget : Suivi
'bud_rem_school' => 'Escola (Resta estimada)', 'bud_rem_school' => 'Escola (Resta estimada)',
'bud_rem_fmcg' => 'Compres (Resta estimada)', 'bud_rem_fmcg' => 'Compres (Resta estimada)',
'bud_rem_fuel' => 'Benzina (Resta estimada)', 'bud_rem_fuel' => 'Benzina (Resta estimada)',
@@ -350,16 +672,16 @@ return [
'bud_import_csv' => 'Importar CSV', 'bud_import_csv' => 'Importar CSV',
'bud_csv_file' => 'Fitxer CSV (Punt i coma)', 'bud_csv_file' => 'Fitxer CSV (Punt i coma)',
'bud_preview' => 'Previsualitzar', 'bud_preview' => 'Previsualitzar',
'bud_validate_import' => 'Validar importació', 'bud_validate_import' => 'Si us plau, comproveu i categoritzeu les línies abans de validar.',
'bud_assign_month' => 'Assignar al mes', 'bud_assign_month' => 'Mes de gestió',
'bud_op_date' => 'Data Operació', 'bud_op_date' => 'Data',
'bud_label' => 'Concepte', 'bud_label' => 'Concepte',
'bud_amount' => 'Import', 'bud_amount' => 'Import',
'bud_category' => 'Categoria', 'bud_category' => 'Categoria',
'bud_ignore' => 'Ignorar', 'bud_ignore' => 'Ignorar (Crèdit)',
'bud_to_define' => 'Per definir', 'bud_to_define' => 'Per definir',
'bud_is_charge' => 'Despesa?', 'bud_is_charge' => 'És un càrrec fix...',
'bud_is_income' => 'Ingrés?', 'bud_is_income' => 'És un ingrés...',
'btn_import' => 'Importar', 'btn_import' => 'Importar',
'bud_no_lines' => 'Cap línia.', 'bud_no_lines' => 'Cap línia.',
'bud_confirm_delete' => 'Suprimir aquesta línia?', 'bud_confirm_delete' => 'Suprimir aquesta línia?',
@@ -378,23 +700,12 @@ return [
'bud_add_title' => 'Afegir', 'bud_add_title' => 'Afegir',
'bud_edit_title' => 'Modificar la transacció', 'bud_edit_title' => 'Modificar la transacció',
'bud_to_define_js' => 'per definir', 'bud_to_define_js' => 'per definir',
'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.',
// --- MODULE BUDGET : IMPORT CSV --- 'bud_select_beneficiary' => 'Triar un ingrés esperat',
'bud_import_csv' => "Importar CSV",
'bud_csv_file' => "Seleccioneu el fitxer del vostre banc (.csv)",
'bud_preview' => "Previsualitzar",
'bud_validate_import' => "Si us plau, comproveu i categoritzeu les línies abans de validar.",
'bud_assign_month' => "Mes de gestió",
'bud_op_date' => "Data",
'bud_amount' => "Import",
'bud_ignore' => "Ignorar (Crèdit)",
'bud_to_define' => "Per definir",
'bud_is_charge' => "És un càrrec fix...",
'bud_is_income' => "És un ingrés...",
// --- BUDGET : RECAP --- // Budget : Recap
'bud_recap_monthly_title' => 'Resum Mensual', 'bud_recap_monthly_title' => 'Resum Mensual',
'bud_recap_btn_add' => 'Afegir una despesa / ingrés', 'bud_recap_btn_add' => 'Afegir una despesa / ingrés',
'bud_planned_amount' => 'Import Previst', 'bud_planned_amount' => 'Import Previst',
@@ -434,42 +745,41 @@ return [
'bud_label_regularization' => 'Regularització', 'bud_label_regularization' => 'Regularització',
'bud_reg_none' => 'Cap', 'bud_reg_none' => 'Cap',
'bud_recap_confirm_delete' => 'Segur que vols suprimir aquest element?', 'bud_recap_confirm_delete' => 'Segur que vols suprimir aquest element?',
'bud_err_delete' => 'Error en suprimir.', 'bud_prev_monthly_target' => 'Import objectiu / Mensualitat (€)',
'bud_sav_modal_title_add' => 'Introduir un mes', 'bud_prev_transfer_dest' => 'Destinació de la transferència',
'bud_sav_ph_name' => 'Nom (ex: Vacances)', 'bud_prev_none' => 'Cap destinació (Despesa estàndard)',
'bud_prev_label_name' => 'Nom',
// --- BUDGET : EPARGNE --- // Budget : Epargne
'bud_sav_add_month' => 'Introduir un mes', 'bud_sav_add_month' => 'Introduir un mes',
'bud_sav_add_one_month' => '+1 Mes', 'bud_sav_add_one_month' => '+1 Mes',
'bud_sav_no_data' => 'Cap dada per a %s.', 'bud_sav_no_data' => 'Cap dada per a %s.',
'bud_sav_post_month' => 'Concepte / Mes', 'bud_sav_post_month' => 'Concepte / Mes',
'bud_sav_from_date' => 'Des del %s', 'bud_sav_from_date' => 'Des del %s',
'bud_sav_edit_modal' => 'Modificar amb Modal', 'bud_sav_edit_modal' => 'Editar aquest mes',
'bud_sav_delete_month' => 'Suprimir tot el mes', 'bud_sav_delete_month' => 'Suprimir aquest mes',
'bud_sav_total_bank' => 'Total Banc', 'bud_sav_total_bank' => 'TOTAL BANC',
'bud_sav_extra' => 'Extra', 'bud_sav_extra' => 'NO ASSIGNAT (EXTRES)',
'bud_sav_modal_title_add' => 'Introduir un mes', 'bud_sav_modal_title_add' => 'Afegir un mes',
'bud_sav_modal_title_edit' => 'Modificar:', 'bud_sav_modal_title_edit' => 'Editar',
'bud_sav_month_concerned' => 'Mes afectat', 'bud_sav_month_concerned' => 'Mes afectat',
'bud_sav_total_bank_eur' => 'Total al Banc (€)', 'bud_sav_total_bank_eur' => 'Total al Banc (€)',
'bud_sav_ventilation' => 'Desglossament', 'bud_sav_ventilation' => 'Desglossament de comptes',
'bud_sav_adj_help' => 'Utilitzeu l\'ajust (+/-) per recalcular automàticament.', 'bud_sav_adj_help' => 'Utilitzeu l\'ajust (+/-) per recalcular automàticament.',
'bud_sav_add_line' => 'Línia', 'bud_sav_add_line' => 'Afegir una línia',
'bud_sav_current' => 'Actual', 'bud_sav_current' => 'Actual',
'bud_sav_adjust' => 'Ajust (+/-)', 'bud_sav_adjust' => 'Ajust',
'bud_sav_new' => 'Nou', 'bud_sav_new' => 'Nou',
'bud_sav_sum_mode_title' => 'Activar la suma ràpida', 'bud_sav_sum_mode_title' => 'Activar/Desactivar la calculadora',
'bud_sav_selection' => 'Selecció:', 'bud_sav_selection' => 'Selecció:',
'bud_sav_ph_name' => 'Nom (ex: Vacances)', 'bud_sav_ph_name' => 'Nom del compte',
'bud_sav_saving' => 'Desant...', 'bud_sav_new_line' => 'Nova línia',
'bud_err_tech' => 'S\'ha produït un error tècnic.', 'bud_sav_prompt_new_line' => 'Nom de la nova línia d\'estalvi:',
'bud_sav_confirm_delete_month' => 'Suprimir TOT el mes de %m per a %o?', 'bud_sav_confirm_delete_month' => 'Estàs segur que vols suprimir totes les dades de %m per a %o?',
'bud_sav_prompt_duplicate' => "Vols copiar les dades de %s a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:", 'bud_sav_prompt_duplicate' => "Vols duplicar les dades de %s cap a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
'bud_err_server' => 'Error del servidor: ',
'bud_err_network_dup' => 'Error de xarxa en duplicar.', 'bud_err_network_dup' => 'Error de xarxa en duplicar.',
'bud_sav_saving' => 'Desant...',
// --- BUDGET : PREVISIONNEL --- // Budget : Previsionnel
'bud_prev_incomes' => 'Ingressos', 'bud_prev_incomes' => 'Ingressos',
'bud_prev_person' => 'Persona', 'bud_prev_person' => 'Persona',
'bud_prev_salary' => 'Sou', 'bud_prev_salary' => 'Sou',
@@ -503,53 +813,94 @@ 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_label_name' => 'Nom',
'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_transfer_to' => 'Cap a',
// Budget : Tricount / Avances
'bud_adv_title' => 'Avançaments i Tricount Familiar',
'bud_adv_add' => 'Afegir un avançament',
'bud_adv_edit' => 'Modificar l\'avançament',
'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 <strong>%s €</strong> a <strong>%s</strong>',
'bud_adv_balanced' => 'Els comptes estan perfectament equilibrats!',
'bud_adv_saved_badge' => 'Estalvis',
'bud_adv_already_saved' => 'Aquests diners provenen d\'un llibre d\'estalvis',
'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?',
// Budget : Provisions & Optimisation
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
'btn_optimize_cashflow' => 'Optimiser mes Économies',
'add_new_provision' => 'Anticiper une grosse dépense à venir',
'provision_label' => 'Libellé de la dépense',
'provision_placeholder_wood' => 'Ex: Commande bois de chauffage, Révision voiture...',
'amount' => 'Montant',
'expected_date' => 'Date prévue',
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
'savings_this_month' => 'Économies globales à placer ce mois-ci',
'calculate' => 'Calculer la stratégie optimale',
'date_past_warning' => 'La date sélectionnée est déjà passée !',
// ========================================== // ==========================================
// CADEAUX (GIFTS) // 8. CADEAUX (GIFTS)
// ========================================== // ==========================================
'gift_page_title' => 'HouseHub - Llista de regals', 'gift_page_title' => 'Llista de Regals',
'gift_occ_tio' => 'Tió', 'gift_main_title' => 'Regals de %s',
'gift_occ_noel' => 'Nadal', 'gift_filter_all_children' => 'Tots els nens',
'gift_occ_rois' => 'Reis', 'gift_filter_all_adults' => 'Tots els adults',
'gift_occ_anniv' => 'Aniversari', 'gift_add_gift' => 'Afegir',
'gift_occ_sant' => 'Sant', 'gift_empty_state_no_gifts' => 'Cap regal registrat per a aquesta ocasió.',
'gift_main_title' => 'Llista de regals %s', 'gift_empty_state_no_filter' => 'Cap regal coincideix amb els teus filtres.',
'gift_aria_change_view' => 'Canvia la vista', 'gift_paid_by' => 'Pagat per %s',
'gift_view_nadal' => 'Nadal', 'gift_liquidations' => 'Balanç i Reemborsaments',
'gift_view_anniv' => 'Aniversaris', 'gift_no_debt' => 'Tot està al dia, cap deute!',
'gift_view_by_party' => 'Vista per festa', 'gift_owes' => 'deu',
'gift_no_gifts' => 'No hi ha cap regal registrat per a %s en aquesta vista.', 'gift_view_matrix' => 'Veure la matriu detallada',
'gift_add_gift' => 'Afegeix un regal', 'gift_debtor' => 'Deutor',
'gift_paid_by' => 'pagat per %s', 'gift_creditor' => 'Creditor',
'gift_summary_title' => 'Resum del pressupost', 'gift_col_adult' => 'Per compte de',
'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_payer' => 'Pagat per',
'gift_modal_gift_name' => 'Nom del regal', 'gift_modal_gift_name' => 'Descripció del regal',
'gift_modal_ph_name' => 'p. ex., Lego Star Wars', 'gift_modal_price' => 'Preu estimat / pagat (€)',
'gift_modal_price' => 'Preu (€)', 'gift_modal_link' => 'Enllaç del producte (URL)',
'gift_modal_link' => 'Enllaç (opcional)', 'gift_modal_title_add' => 'Afegir per a %s',
'gift_confirm_delete' => 'Vols eliminar aquest regal?', 'gift_modal_title_edit' => 'Modificar el regal',
'gift_filter_all_children' => 'Tots els nens', 'gift_confirm_delete' => 'Vols eliminar realment aquest regal?',
'gift_filter_all_adults' => 'Tots els adults', 'gift_settings_title' => 'Configuració de les Festes',
'gift_empty_state_no_gifts' => 'Cap regal de moment.', 'gift_settings_add_title' => '+ Afegir una ocasió',
'gift_empty_state_no_filter' => 'Cap regal correspon al filtre.', '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.',
'gift_settings_active' => 'Actiu',
'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 // 9. GARAGE
// ========================================== // ==========================================
'menu_garage' => 'Garatge',
'garage_page_title' => 'HouseHub - Garatge', 'garage_page_title' => 'HouseHub - Garatge',
'garage_stat_vehicles' => 'Vehicles', 'garage_stat_vehicles' => 'Vehicles',
'garage_stat_maintenances' => 'Manteniments', 'garage_stat_maintenances' => 'Manteniments',
@@ -558,7 +909,7 @@ return [
'garage_my_vehicles' => 'Els meus vehicles', 'garage_my_vehicles' => 'Els meus vehicles',
'garage_reminders' => 'Recordatoris i propers manteniments', 'garage_reminders' => 'Recordatoris i propers manteniments',
'garage_vehicles_title' => 'Els meus vehicles', 'garage_vehicles_title' => 'Els meus vehicles',
'garage_all_parts' => 'Totes les peces', 'garage_all_parts' => 'Toutes les peces',
'garage_parts_count' => 'peces', 'garage_parts_count' => 'peces',
'garage_maintenances' => 'Manteniments', 'garage_maintenances' => 'Manteniments',
'garage_parts' => 'Peces', 'garage_parts' => 'Peces',
@@ -579,7 +930,6 @@ return [
'garage_photo' => 'Foto', 'garage_photo' => 'Foto',
'garage_notes' => 'Notes', 'garage_notes' => 'Notes',
'garage_maint_type' => 'Tipus de manteniment', 'garage_maint_type' => 'Tipus de manteniment',
'garage_date' => 'Data',
'garage_km_at' => 'Quilometratge en el moment', 'garage_km_at' => 'Quilometratge en el moment',
'garage_labor_cost' => 'Cost mà d\'obra (€)', 'garage_labor_cost' => 'Cost mà d\'obra (€)',
'garage_description' => 'Descripció', 'garage_description' => 'Descripció',
@@ -598,4 +948,79 @@ return [
'garage_change_photo' => 'Canviar la foto', 'garage_change_photo' => 'Canviar la foto',
'garage_new_photo' => 'Nova foto', 'garage_new_photo' => 'Nova foto',
'garage_update_photo' => 'Actualitzar', 'garage_update_photo' => 'Actualitzar',
];
// ==========================================
// 10. LISTES & COURSES
// ==========================================
'liste_placeholder_add' => 'Afegir un article...',
'liste_btn_add' => 'Afegir',
'liste_btn_uncheck_all' => 'Tot desmarcar',
'liste_btn_delete_picked' => 'Suprimir els marcats',
'liste_btn_clear_all' => 'Buidar la llista',
'liste_subtitle' => '{pending} pendents · {cart} marcats',
'liste_added' => 'Afegit',
'liste_updated' => 'Modificat',
'liste_deleted' => 'Suprimit',
'liste_already_in' => 'Ja a la llista',
'liste_confirm_clear' => 'Buidar tota la llista?',
'liste_confirm_delete_list' => 'Suprimir aquesta llista i tots els articles?',
'liste_history_title' => 'Recents — clic per afegir',
'liste_new_list' => 'Nova llista',
'liste_list_name_placeholder'=> 'Nom de la llista...',
'liste_create' => 'Crear',
'liste_rename_list' => 'Renombrar',
'liste_new_name' => 'Nou nom:',
'liste_list_deleted' => 'Llista suprimida',
'liste_list_created' => 'Llista creada',
'liste_list_renamed' => 'Llista renombrada',
'liste_edit_placeholder' => 'Modificar l\'article:',
'liste_empty_state' => 'Creeu la primera llista amb el botó + de dalt.',
'liste_settings_title' => 'Llistes — historial',
'liste_settings_intro' => 'Nombre màxim d\'articles memoritzats a l\'historial (comú per a totes).',
'liste_settings_history_max' => 'Mida de l\'historial',
'liste_settings_history_hint'=> 'Entre 1 i 50.',
'groceries_settings_saved' => 'Preferències desades.',
'groceries_settings_error' => 'Impossible de desar:',
'groceries_err_duplicate' => 'Aquest producte ja és a la llista.',
// --- MODULE MEALS ---
'menu_meals' => 'Menús',
'mod_meals_name' => 'Planificació de Menús',
'mod_meals_desc' => 'Planifica els àpats de la setmana per a tota la família.',
'meals_board_title' => 'Planificació de Menús',
'meals_copy_lunch' => 'Copiar tot el dinar',
'meals_copy_dinner' => 'Copiar tot el sopar',
'meal_lunch' => 'Migdia',
'meal_dinner' => 'Sopar',
'btn_apply_all' => 'Tots',
'btn_copy_to' => 'Copiar cap a...',
'modal_copy_title' => 'Copiar cap a...',
'source_individual' => 'Plat individual',
'source_row' => 'Àpat complet',
'week_current' => 'Setmana actual',
'week_next' => 'Setmana vinent',
'meals_no_members' => 'Si us plau, configureu els membres de la vostra família a la configuració.',
'day_full_mon' => 'Dilluns', 'day_full_tue' => 'Dimarts', 'day_full_wed' => 'Dimecres',
'day_full_thu' => 'Dijous', 'day_full_fri' => 'Divendres', 'day_full_sat' => 'Dissabte', 'day_full_sun' => 'Diumenge',
'meal_title' => 'Planificador de Menjars',
'meal_edit_title' => 'Modificar el menjar',
'meal_label_name' => 'Menú previst',
'meal_week' => 'Setmana',
'meal_ph_name' => 'Ex: Lasanya casolana',
'meal_empty_slot' => 'Per definir...',
'meal_current_week' => 'Setmana actual',
'meal_saved' => 'Menjar guardat!',
'meal_btn_import_pdf' => 'Import Menu PDF',
'meal_import_title' => 'Import canteen menu',
'meal_import_child' => 'For which child?',
'meal_select_child' => 'Select child',
'meal_pdf_file' => 'PDF File (Convivio)',
'meal_drop_pdf' => 'Click to add PDF',
'meal_import_success' => 'Menus imported successfully!',
'mod_food_name' => 'Cuina i Compres',
'food_tab_meals' => 'Menú de la setmana',
'food_tab_list' => 'Llista de la compra',
'menu_food' => 'Menjar',
'mod_food_desc' => 'Gestioneu els vostres menús setmanals i les llistes de la compra compartides.',
'cta_food' => 'Cuinar',
];
+550 -108
View File
@@ -1,28 +1,23 @@
<?php <?php
// English Dictionary // English Dictionary - HouseHub
return [ return [
// ========================================== // ==========================================
// TEST AUTO // 1. GLOBAL, NAVIGATION & ACTIONS
// ==========================================
'test_running' => 'Running tests...',
'test_passed' => 'Test passed',
'test_failed' => 'Test failed',
'tests_title' => 'Test Lab',
'tests_run_budget' => 'Test Budget',
'tests_run_all' => 'Run all tests',
'tests_copy_report' => 'Copy report',
'tests_waiting' => 'Waiting to start...',
'tests_report_copied' => 'Report copied to clipboard!',
'tests_live_preview' => 'Live preview',
// ==========================================
// GLOBAL & NAVIGATION
// ========================================== // ==========================================
'menu_home' => 'Home', 'menu_home' => 'Home',
'menu_calendar' => 'Calendar', 'menu_calendar' => 'Calendar',
'menu_budget' => 'Budget', 'menu_budget' => 'Budget',
'menu_holidays' => 'Trips', 'menu_holidays' => 'Trips',
'menu_gifts' => 'Gifts', 'menu_gifts' => 'Gifts',
'menu_garage' => 'Garage',
'menu_memo' => 'Notes',
'menu_todo' => 'Todo',
'menu_liste' => 'Lists',
'menu_groceries' => 'Groceries',
'menu_calendar_ios' => 'iOS Calendar',
'menu_printvault' => 'PrintVault',
'menu_planka' => 'Planka',
'nav_home' => 'Home', 'nav_home' => 'Home',
'nav_calendar' => 'Calendar', 'nav_calendar' => 'Calendar',
'nav_budget' => 'Budget', 'nav_budget' => 'Budget',
@@ -30,7 +25,6 @@ return [
'nav_gifts' => 'Gifts', 'nav_gifts' => 'Gifts',
'aria_menu' => 'Mobile menu', 'aria_menu' => 'Mobile menu',
// Buttons & global actions
'actions' => 'Actions', 'actions' => 'Actions',
'edit' => 'Edit', 'edit' => 'Edit',
'delete' => 'Delete', 'delete' => 'Delete',
@@ -40,28 +34,33 @@ return [
'btn_cancel' => 'Cancel', 'btn_cancel' => 'Cancel',
'btn_delete' => 'Delete', 'btn_delete' => 'Delete',
'btn_close' => 'Close', 'btn_close' => 'Close',
'btn_add' => 'Add',
'btn_back' => '◀ Back', 'btn_back' => '◀ Back',
'btn_edit_bases' => '⚙️ Edit base values', 'btn_edit_bases' => '⚙️ Edit base values',
'btn_edit' => 'Edit', 'btn_edit' => 'Edit',
'error_occured' => 'An error occurred.',
// Generic words & errors
'error' => 'Error.', 'error' => 'Error.',
'error_occured' => 'An error occurred.',
'error_network' => 'Network error',
'error_generic' => 'An error occurred',
'confirm_delete' => 'Are you sure you want to delete this item?', 'confirm_delete' => 'Are you sure you want to delete this item?',
'loading' => 'Loading...',
'date' => 'Date',
'settings' => 'Settings',
// Months & Days // Months & Days
'month_01' => 'January', 'month_02' => 'February', 'month_03' => 'March', 'month_01' => 'January', 'month_02' => 'February', 'month_03' => 'March', 'month_04' => 'April', 'month_05' => 'May', 'month_06' => 'June',
'month_04' => 'April', 'month_05' => 'May', 'month_06' => 'June', 'month_07' => 'July', 'month_08' => 'August', 'month_09' => 'September', 'month_10' => 'October', 'month_11' => 'November', 'month_12' => 'December',
'month_07' => 'July', 'month_08' => 'August', 'month_09' => 'September', 'month_jan' => 'January', 'month_feb' => 'February', 'month_mar' => 'March', 'month_apr' => 'April', 'month_may' => 'May', 'month_jun' => 'June',
'month_10' => 'October', 'month_11' => 'November', 'month_12' => 'December', 'month_jul' => 'July', 'month_aug' => 'August', 'month_sep' => 'September', 'month_oct' => 'October', 'month_nov' => 'November', 'month_dec' => 'December',
'day_mon' => 'Mon', 'day_tue' => 'Tue', 'day_wed' => 'Wed', 'day_mon' => 'Mon', 'day_tue' => 'Tue', 'day_wed' => 'Wed', 'day_thu' => 'Thu', 'day_fri' => 'Fri', 'day_sat' => 'Sat', 'day_sun' => 'Sun',
'day_thu' => 'Thu', 'day_fri' => 'Fri', 'day_sat' => 'Sat', 'day_sun' => 'Sun',
// ========================================== // ==========================================
// AUTHENTICATION / LOGIN // 2. AUTHENTICATION / LOGIN
// ========================================== // ==========================================
'login_title' => 'Login - HouseHub', 'login_title' => 'Login - HouseHub',
'login_header' => 'Login', 'login_header' => 'Login',
'login_subtitle' => 'Please sign in to continue',
'label_username' => 'Username', 'label_username' => 'Username',
'label_password' => 'Password', 'label_password' => 'Password',
'placeholder_username' => 'e.g. john', 'placeholder_username' => 'e.g. john',
@@ -69,18 +68,19 @@ return [
'btn_login_submit' => 'Sign in', 'btn_login_submit' => 'Sign in',
'logout' => 'Logout', 'logout' => 'Logout',
'btn_logout' => 'Logout', 'btn_logout' => 'Logout',
'login_remember_me' => 'Remember me',
'error_missing_fields' => 'All fields are required.', 'error_missing_fields' => 'All fields are required.',
'error_invalid_credentials' => 'Invalid credentials.', 'error_invalid_credentials' => 'Invalid credentials.',
'login_subtitle' => 'Please sign in to continue',
// ========================================== // ==========================================
// HOME / INDEX // 3. HOME & DASHBOARD
// ========================================== // ==========================================
'home_title' => 'HouseHub - Home', 'home_title' => 'HouseHub - Home',
'home_welcome' => 'Welcome to HouseHub', 'home_welcome' => 'Welcome to HouseHub',
'home_subtitle' => 'Family organisation control centre.', 'home_subtitle' => 'Family organisation control centre.',
'home_logged_as' => 'Logged in as', 'home_logged_as' => 'Logged in as',
'home_modules_title' => 'Modules', 'home_modules_title' => 'Modules',
'mod_calendar_name' => 'Family Calendar', 'mod_calendar_name' => 'Family Calendar',
'mod_calendar_desc' => 'Manage leave, childcare arrangements and weekly planning.', 'mod_calendar_desc' => 'Manage leave, childcare arrangements and weekly planning.',
'mod_holidays_name' => 'Trips', 'mod_holidays_name' => 'Trips',
@@ -91,19 +91,153 @@ return [
'mod_budget_desc' => 'Track fixed expenses, income and family account balance.', 'mod_budget_desc' => 'Track fixed expenses, income and family account balance.',
'mod_garage_name' => 'Garage', 'mod_garage_name' => 'Garage',
'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.', 'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Your memos, recipes, instructions and miscellaneous info with Markdown and attachments.',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Manage tasks, shopping lists and family reminders.',
'mod_liste_name' => 'Lists',
'mod_liste_desc' => 'Shared named lists — groceries, DIY, pharmacy… As many lists as needed.',
'mod_groceries_name' => 'Groceries',
'mod_groceries_desc' => 'Shared list: add items and check them off when they are in your cart.',
'mod_calendar_ios_name' => 'iOS Calendar',
'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).',
'mod_printvault_name'=> 'PrintVault',
'mod_printvault_desc'=> 'Manage your 3D print files — STL, 3MF, GCode with interactive viewer.',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Manage your projects in Kanban mode with Planka.',
'cta_open' => 'Open', 'cta_open' => 'Open',
'cta_explore' => 'Explore', 'cta_explore' => 'Explore',
'cta_view_lists' => 'View lists', 'cta_view_lists' => 'Unwrap',
'cta_manage' => 'Manage', 'cta_manage' => 'Manage',
'cta_service' => 'Service',
'cta_jot' => 'Jot down',
'cta_check' => 'Check off',
'cta_cart' => 'Go shopping',
'cta_sync' => 'Sync',
'cta_print' => 'Print',
// ========================================== // ==========================================
// FAMILY CALENDAR // 4. SETTINGS
// ==========================================
'set_title_page' => '⚙️ Settings',
'set_title_lang' => '🌐 Language',
'set_title_modules' => '🧩 Active Modules',
'set_desc_modules' => 'Choose the modules visible in the navigation.',
'set_title_ios' => '📱 iOS Calendar Integration (CalDAV)',
'set_desc_ios' => 'Configure your iCloud calendar here to sync events.',
'set_label_apple_id' => 'Apple ID (iCloud email)',
'set_label_apple_pwd' => 'Apple app password',
'set_label_apple_url' => 'CalDAV calendar URL',
'set_btn_test_ios' => 'Test connection',
'set_btn_disconnect_ios' => 'Disconnect',
'set_title_bg' => '🖼️ Home Image',
'set_desc_bg' => 'Background photo displayed on the homepage.',
'set_badge_custom_bg' => 'Custom image',
'set_label_new_bg' => 'New image (jpg, png, webp — max 10 MB)',
'set_btn_reset_bg' => '🔄 Reset to default image',
'set_confirm_reset_bg' => 'Revert to default image?',
'set_title_profile' => '👤 My Profile',
'set_label_username' => 'Username',
'set_label_display_name' => 'First name / Display name',
'set_label_profile_color' => 'My profile theme color',
'set_desc_profile_color' => 'This color will be used for your budget grids, calendars, and personal indicators.',
'set_title_pwd' => '🔒 Change Password',
'set_label_cur_pwd' => 'Current password',
'set_label_new_pwd' => 'New password',
'set_label_confirm_pwd' => 'Confirm',
'set_btn_edit' => 'Edit',
'set_title_family' => '🏠 My Family Space',
'set_label_family_name' => 'Space name',
'set_desc_invite' => 'Invitation code — share it to invite someone',
'set_title_copy' => 'Click to copy',
'set_msg_copied' => '✓ Copied!',
'set_btn_new_code' => '🔄 New code',
'set_confirm_new_code' => 'Regenerate code? The old one will be invalidated.',
'set_desc_register_link' => 'Registration link:',
'set_title_members' => 'Members',
'set_badge_admin' => 'Admin',
'set_title_attached' => '🧸 Attached Profiles',
'set_desc_attached' => 'Household members without direct access.',
'set_empty_attached' => 'No attached profiles.',
'set_link_admin' => '→ Administration Panel',
'set_kids_title' => 'Household Kids',
'set_kids_desc' => 'Manage the list of kids to associate them with savings, gifts, etc.',
'set_add_kid_placeholder' => 'Kid\'s first name',
'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' => 'Add a mode',
// Labels Profiles
'label_firstname' => 'First Name',
'placeholder_firstname_ex' => 'e.g., Pol, Carole...',
'label_role' => 'Role',
'role_child' => '👶 Child',
'role_child_icon' => '👶 Child',
'role_helper' => '💼 Helper',
'role_helper_icon' => '💼 Helper',
'role_relative' => '👵 Relative',
'role_relative_icon' => '👵 Relative',
'role_unknown' => 'Unknown',
'label_color' => 'Color',
'change_color' => 'Change color',
'confirm_delete_profile' => 'Permanently delete this profile?',
// Settings Messages
'error_csrf' => 'Invalid session (CSRF). Please reload the page.',
'set_err_min_module' => 'You must keep at least one active module.',
'set_success_modules' => 'Modules successfully updated.',
'set_success_lang' => 'Language updated.',
'set_err_empty_name' => 'First name cannot be empty.',
'set_success_profile' => 'Profile updated successfully.',
'set_err_wrong_pwd' => 'Incorrect current password.',
'set_err_pwd_length' => 'New password must be at least 6 characters long.',
'set_err_pwd_match' => 'Passwords do not match.',
'set_success_pwd' => 'Password successfully changed.',
'set_success_family' => 'Space name updated.',
'set_success_invite' => 'New invitation code generated.',
'set_err_bg_format' => 'Unsupported format (jpg, png, webp).',
'set_success_bg' => 'Home image updated.',
'set_err_upload' => 'Error during upload.',
'set_success_bg_reset' => 'Home image reset.',
'set_err_db_unavailable' => 'Family database unavailable.',
'set_err_ios_missing' => 'Please provide an iCloud ID, app password, and CalDAV URL.',
'set_success_ios' => 'iOS calendar connection saved.',
'set_success_ios_auto' => ' Calendar URL automatically detected.',
'set_err_ios_save' => 'Cannot save iOS connection: ',
'set_err_ios_none' => 'No iOS connection configured.',
'set_success_ios_test' => 'Valid iCloud CalDAV connection (HTTP %s).',
'set_err_ios_test' => 'Connection test failed (HTTP %s). Check your credentials.',
'set_err_ios_test_fail' => 'Connection test unavailable: ',
'set_success_ios_del' => 'iOS connection removed.',
'set_success_profile_add' => 'Profile for %s successfully added.',
'set_err_name_required' => 'First name is required.',
'set_success_profile_del' => 'Profile deleted.',
'set_success_color' => 'Color updated!',
'set_success_color_profile' => 'Profile color updated.',
'set_success_color_update' => 'Color updated!',
'color_updated' => 'Color updated!',
'settings_profile_added' => 'Profile of %s added successfully.',
'settings_err_name_required' => 'First name is required.',
'settings_attached_profiles_title' => 'Attached Profiles',
'settings_attached_profiles_desc' => 'Household members without direct access.',
'settings_no_attached_profiles' => 'No attached profiles.',
'placeholder_apple_id' => 'name@icloud.com',
'placeholder_apple_pwd' => 'xxxx-xxxx-xxxx-xxxx',
// ==========================================
// 5. FAMILY CALENDAR
// ========================================== // ==========================================
'fc_page_title' => 'HouseHub - Calendar', 'fc_page_title' => 'HouseHub - Calendar',
'fc_main_header' => 'Family Calendar', 'fc_main_header' => 'Family Calendar',
'fc_btn_correct_balances' => 'Adjust balances', 'fc_btn_correct_balances' => 'Adjust balances',
'fc_btn_school_holidays' => 'School holidays', 'fc_btn_school_holidays' => 'School holidays',
'fc_modal_holidays_title' => 'School Holidays (Zone C)', 'fc_modal_holidays_title' => 'School Holidays',
'fc_modal_snap_title' => 'Adjust a balance', 'fc_modal_snap_title' => 'Adjust a balance',
'fc_col_period' => 'Period', 'fc_col_period' => 'Period',
'fc_col_from' => 'From', 'fc_col_from' => 'From',
@@ -122,6 +256,7 @@ return [
'fc_weekly_planning' => 'Weekly planning', 'fc_weekly_planning' => 'Weekly planning',
'fc_col_month' => 'Month', 'fc_col_month' => 'Month',
'fc_col_week_short' => 'Wk.', 'fc_col_week_short' => 'Wk.',
'fc_col_week' => 'Week',
'fc_col_av' => 'Av.', 'fc_col_av' => 'Av.',
'fc_col_use' => 'Use', 'fc_col_use' => 'Use',
'fc_legend_title' => 'Legend', 'fc_legend_title' => 'Legend',
@@ -132,14 +267,98 @@ return [
'fc_unit_days' => 'days', 'fc_unit_days' => 'days',
'fc_menu_carole' => 'Carole\'s Leave', 'fc_menu_carole' => 'Carole\'s Leave',
'fc_clear' => 'Clear', 'fc_clear' => 'Clear',
'fc_menu_kids_leaves' => 'Children\'s Leave', 'fc_menu_kids_leaves' => 'Parent\'s Leave',
'fc_err_save_snap' => 'Error saving adjustment.', 'fc_err_save_snap' => 'Error saving adjustment.',
'fc_err_fetch_gov' => 'Error connecting to the government API.', 'fc_err_fetch_gov' => 'Error connecting to the government API.',
'fc_err_no_data_gov' => 'No data found on the government API for this year.', 'fc_err_no_data_gov' => 'No data found on the government API for this year.',
'fc_err_action' => 'Error during action: ', 'fc_err_action' => 'Error during action: ',
'fc_err_select_leave' => 'Please select a leave type.',
'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_role_helper' => 'Helper',
'fc_role_unknown' => 'Unknown',
'fc_care_modes_title' => 'Associated Care Modes',
'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_a' => 'Zone A',
'fc_zone_b' => 'Zone B',
'fc_zone_c' => 'Zone C',
'fc_zone_other' => 'Other / Outside France',
'fc_zone_desc' => 'Determines the automatic school holiday calendar.',
'fc_care_modes_label' => 'Household Care Modes',
'fc_care_modes_desc' => 'Manage the global list of possible care modes for the family.',
'fc_add_mode_placeholder' => 'E.g., Nanny, Center, Grandparents...',
'fc_empty_kids_msg' => 'No children are configured in your household yet.',
'fc_link_settings' => 'Go to general settings',
'fc_settings_updated' => 'Settings updated successfully!',
'fc_first_day' => 'First day of the week',
'fc_helper_recap' => 'Helper Summary',
'fc_no_helper_leaves' => 'No leaves taken this month.',
'fc_col_date' => 'Date',
'fc_col_type' => 'Type',
'fc_col_duration' => 'Duration',
'fc_full_year' => 'Full year',
'fc_custom_period' => 'Custom period...',
'fc_worked_days' => 'Worked (Est.)',
'fc_tab_display' => '🖥️ Display',
'fc_display_title' => 'Display Settings',
'fc_display_desc' => 'Customize the default appearance and behavior of your calendar.',
'fc_default_view' => 'Default View',
'fc_working_hours' => 'Default Care Hours',
'fc_working_hours_desc' => 'Example: 08:30-18:00',
'fc_error' => 'Error: ',
'fc_leave_catalog_title' => 'Leave Catalog',
'fc_leave_catalog_desc' => 'Define the possible leave types for the whole family.',
'fc_add_leave_type' => 'Add a leave type',
'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',
'fc_leave_obsolete' => 'Obsolete',
'fc_leave_code_placeholder' => 'Code...',
'fc_leave_method_fixed' => 'Fixed',
'fc_leave_method_accumulated'=> 'Accrued',
'fc_date_format_placeholder'=> 'DD/MM',
'fc_date_format_title' => 'DD/MM Format',
'fc_table_code' => 'Code',
'fc_table_method' => 'Method',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renewal',
'fc_label_carry_over' => 'Incl. carried over days (Expiring) ⚠️',
'fc_carry_over_note' => 'Portion of the overall balance that will expire on the deadline.',
'fc_carry_max' => 'Max Carry Over',
'fc_carry_deadline' => 'Expiration Month',
'fc_deadline_none' => 'None',
'fc_allowance_help' => 'Fixed: Annual quota. Accrued: Added per month.',
'fc_carry_max_short' => 'Max carry-over',
'fc_edit_leave' => '✏️ Edit:',
'fc_not_modifiable' => 'Not modifiable',
'fc_add_leave' => '+ Add',
'fc_err_code_label' => 'Code and Label are required.',
'fc_catalog_updated' => 'Catalog updated!',
'fc_balance_corrected' => 'Balance corrected!',
'fc_no_leave_configured' => 'No leave configured.',
'fc_confirm_del_catalog' => 'Permanently delete leave %s from the catalog? This will also remove it from members using it.',
'fc_confirm_del_member_leave'=> 'Do you really want to remove this leave for this member?',
// Calendar Legends
'leg_presence' => 'Pep presence', 'leg_presence' => 'Pep presence',
'leg_school_holidays' => 'School holidays', 'leg_school_holidays' => 'School holidays',
'leg_public_holiday' => 'Public holiday', 'leg_public_holiday' => 'Public holiday',
@@ -147,8 +366,10 @@ return [
'leg_extra_off' => 'Extra off', 'leg_extra_off' => 'Extra off',
'leg_centre' => 'Centre', 'leg_centre' => 'Centre',
'leg_avis' => 'Notice', 'leg_avis' => 'Notice',
'leg_pep_sick' => 'Pep sick', 'fc_menu_sick' => 'Sick',
'care_nounou' => 'Nanny',
'care_centre' => 'Daycare',
'care_avis' => 'Grandparents',
'vac_toussaint' => 'All Saints\' holidays', 'vac_toussaint' => 'All Saints\' holidays',
'vac_noel' => 'Christmas holidays', 'vac_noel' => 'Christmas holidays',
'vac_hiver' => 'Winter holidays', 'vac_hiver' => 'Winter holidays',
@@ -157,12 +378,11 @@ return [
'vac_ete' => 'Summer holidays', 'vac_ete' => 'Summer holidays',
'btn_off' => 'Off', 'btn_off' => 'Off',
'btn_extra' => 'Extra', 'btn_extra' => 'Extra',
'fc_alert_burn_days' => '⚠️ Risk of losing: %s d to take before end of cycle',
'fc_alert_burn_days' => '⚠️ Risk of losing: %s d to take before %s', 'fc_alert_burn_jra' => '⚠️ Alert: Carried over days expire at the end of this month!',
'fc_alert_burn_jra' => '⚠️ JRA: Only 2 days can be carried over after February!',
// ========================================== // ==========================================
// HOLIDAYS (TRIPS) // 6. HOLIDAYS (TRIPS)
// ========================================== // ==========================================
'hdl_title_list' => 'HouseHub - My Trips', 'hdl_title_list' => 'HouseHub - My Trips',
'hdl_title_detail' => 'HouseHub - Trip details', 'hdl_title_detail' => 'HouseHub - Trip details',
@@ -237,6 +457,8 @@ return [
'hdl_to_place' => 'To place', 'hdl_to_place' => 'To place',
'hdl_from' => 'From', 'hdl_from' => 'From',
'hdl_to' => 'To', 'hdl_to' => 'To',
// Weather & Holidays specific
'weather_forecast' => 'Weather forecast', 'weather_forecast' => 'Weather forecast',
'weather_not_available' => 'Weather unavailable', 'weather_not_available' => 'Weather unavailable',
'temp_max' => 'Max temp.', 'temp_max' => 'Max temp.',
@@ -304,7 +526,7 @@ return [
'to_place' => '📥 To place', 'to_place' => '📥 To place',
// ========================================== // ==========================================
// BUDGET (GLOBAL & CATEGORIES) // 7. BUDGET (GLOBAL, SUIVI, PREV, EPARGNE, PROVISIONS)
// ========================================== // ==========================================
'budget_page_title' => 'HouseHub - Budget', 'budget_page_title' => 'HouseHub - Budget',
'budget_main_header' => 'Budget Management', 'budget_main_header' => 'Budget Management',
@@ -312,9 +534,16 @@ return [
'budget_tab_prev' => 'Budget 2026', 'budget_tab_prev' => 'Budget 2026',
'budget_tab_savings' => 'Savings', 'budget_tab_savings' => 'Savings',
'budget_tab_recap' => 'Summary', 'budget_tab_recap' => 'Summary',
'budget_tab_provisions' => 'Provisions',
'budget_tab_kids' => 'Kids',
'budget_err_file_not_found' => 'Oops! File not found.', 'budget_err_file_not_found' => 'Oops! File not found.',
'budget_err_file_detail' => 'Check that the file views/%s.php exists.', 'budget_err_file_detail' => 'Check that the file views/%s.php exists.',
'budget_err_not_authorized' => 'Page not authorised.', 'budget_err_not_authorized' => 'Page not authorised.',
'bud_err_tech' => 'A technical error occurred.',
'bud_err_server' => 'Server error: ',
'bud_err_delete' => 'Error while deleting.',
// Categories (Budget)
'cat_income' => 'Income', 'cat_income' => 'Income',
'cat_fmcg' => 'Groceries (FMCG)', 'cat_fmcg' => 'Groceries (FMCG)',
'cat_fuel' => 'Fuel', 'cat_fuel' => 'Fuel',
@@ -323,7 +552,104 @@ return [
'cat_others' => 'Other / Unexpected', 'cat_others' => 'Other / Unexpected',
'cat_savings' => 'Savings', 'cat_savings' => 'Savings',
// --- BUDGET: TRACKING --- // Settings (Budget)
'budget_settings_title' => 'Budget Settings',
'bs_tab_accounts' => '💳 Accounts',
'bs_tab_categories' => '🏷️ Categories',
'bs_tab_rules' => '🤖 Import Rules',
'bs_tab_salaries' => '💼 Salaries',
'bs_tab_csv' => '📥 CSV Import',
'bs_empty_accounts' => 'No accounts configured.',
'bs_type_checking' => 'Checking',
'bs_type_savings' => 'Savings',
'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!',
'bud_settings_dyn_title' => 'Dynamic Estimates (Children)',
'bud_dyn_nanny_fixed' => 'Nanny fixed salary (€)',
'bud_dyn_nanny_daily' => 'Daily maintenance fee (€)',
'bud_dyn_nanny_aid' => 'Government aid (€)',
'bud_dyn_nanny_cesu_avg' => 'Average CESU paid (€)',
'bud_dyn_school_meal' => 'School meal price (€)',
'bud_dyn_school_aftercare' => 'After-school care price (€)',
'bud_dyn_school_fullday' => 'Full day care (Wednesdays/Holidays) (€)',
'bud_dyn_pattern_title' => 'Standard school week',
'bud_audit_month' => 'Analyzed month: %02d/%d',
'bud_audit_nanny_abs' => '%s absences deducted : %dd',
'bud_audit_child' => '👦 %s :',
'bud_audit_school_fee' => 'Canteen (%dd) + Aftercare (%dd) + Center (%dd) = %.2f€',
'bud_audit_sick' => 'Sick days deducted : %dd',
'bud_audit_nanny_full' => '💼 %s : Fixed (%.2f€) + Fees (%dd x %.2f€ = %.2f€) - Aid (%.2f€) - CESU (%.2f€) = %.2f€',
// CSV Mapping (Budget)
'bs_csv_title' => 'CSV Import Format',
'bs_csv_desc' => 'Configure the columns that match your bank\'s export file.',
'bs_csv_delimiter' => 'Delimiter',
'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_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',
'bud_err_delete_system_cat' => 'Cannot delete a core system category.',
// Budget : Tracking
'bud_rem_school' => 'School (estimated remaining)', 'bud_rem_school' => 'School (estimated remaining)',
'bud_rem_fmcg' => 'Groceries (estimated remaining)', 'bud_rem_fmcg' => 'Groceries (estimated remaining)',
'bud_rem_fuel' => 'Fuel (estimated remaining)', 'bud_rem_fuel' => 'Fuel (estimated remaining)',
@@ -379,8 +705,9 @@ return [
'bud_bar_actual' => 'Actual', 'bud_bar_actual' => 'Actual',
'bud_bar_theoretical' => 'Theoretical', 'bud_bar_theoretical' => 'Theoretical',
'bud_bar_max_cap' => 'Max capacity', 'bud_bar_max_cap' => 'Max capacity',
'bud_select_beneficiary' => 'Choose an expected income',
// --- BUDGET: RECAP --- // Budget : Recap
'bud_recap_monthly_title' => 'Monthly summary', 'bud_recap_monthly_title' => 'Monthly summary',
'bud_recap_btn_add' => 'Add a cost / income', 'bud_recap_btn_add' => 'Add a cost / income',
'bud_planned_amount' => 'Planned amount', 'bud_planned_amount' => 'Planned amount',
@@ -420,39 +747,41 @@ return [
'bud_label_regularization' => 'Regularisation', 'bud_label_regularization' => 'Regularisation',
'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_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',
'bud_sav_add_one_month' => '+1 Month', 'bud_sav_add_one_month' => '+1 Month',
'bud_sav_no_data' => 'No data for %s.', 'bud_sav_no_data' => 'No data for %s.',
'bud_sav_post_month' => 'Category / Month', 'bud_sav_post_month' => 'Category / Month',
'bud_sav_from_date' => 'From %s', 'bud_sav_from_date' => 'From %s',
'bud_sav_edit_modal' => 'Edit with modal', 'bud_sav_edit_modal' => 'Edit this month',
'bud_sav_delete_month' => 'Delete entire month', 'bud_sav_delete_month' => 'Delete this month',
'bud_sav_total_bank' => 'Total bank', 'bud_sav_total_bank' => 'TOTAL BANK',
'bud_sav_extra' => 'Extra', 'bud_sav_extra' => 'UNALLOCATED (EXTRAS)',
'bud_sav_modal_title_add' => 'Enter a month', 'bud_sav_modal_title_add' => 'Add a month',
'bud_sav_modal_title_edit' => 'Edit:', 'bud_sav_modal_title_edit' => 'Edit',
'bud_sav_month_concerned' => 'Month concerned', 'bud_sav_month_concerned' => 'Target month',
'bud_sav_total_bank_eur' => 'Total in bank (€)', 'bud_sav_total_bank_eur' => 'Total in Bank (€)',
'bud_sav_ventilation' => 'Breakdown', 'bud_sav_ventilation' => 'Accounts breakdown',
'bud_sav_adj_help' => 'Use the adjustment (+/-) to recalculate automatically.', 'bud_sav_adj_help' => 'Enter adjustments (+/-) to auto-calculate.',
'bud_sav_add_line' => 'Line', 'bud_sav_add_line' => 'Add line',
'bud_sav_current' => 'Current', 'bud_sav_current' => 'Current',
'bud_sav_adjust' => 'Adj. (+/-)', 'bud_sav_adjust' => 'Adjustment',
'bud_sav_new' => 'New', 'bud_sav_new' => 'New',
'bud_sav_sum_mode_title' => 'Enable quick addition', 'bud_sav_sum_mode_title' => 'Toggle calculator',
'bud_sav_selection' => 'Selection:', 'bud_sav_selection' => 'Selection:',
'bud_sav_ph_name' => 'Name (e.g. Holidays)', 'bud_sav_ph_name' => 'Account name',
'bud_sav_saving' => 'Saving...', 'bud_sav_new_line' => 'New line',
'bud_err_tech' => 'A technical error occurred.', 'bud_sav_prompt_new_line' => 'Name of the new savings line:',
'bud_sav_confirm_delete_month' => 'Delete ALL of month %m for %o?', '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 copy data from %s to %t1?\n\nEnter the new TOTAL bank balance (€) for %t2:", 'bud_sav_prompt_duplicate' => "Do you want to duplicate data from %s to %t1?\n\nEnter the new BANK TOTAL (€) for %t2:",
'bud_err_server' => 'Server error: ',
'bud_err_network_dup' => 'Network error during duplication.', 'bud_err_network_dup' => 'Network error during duplication.',
'bud_sav_saving' => 'Saving...',
// --- BUDGET: FORECAST --- // Budget : Forecast
'bud_prev_incomes' => 'Income', 'bud_prev_incomes' => 'Income',
'bud_prev_person' => 'Person', 'bud_prev_person' => 'Person',
'bud_prev_salary' => 'Salary', 'bud_prev_salary' => 'Salary',
@@ -487,55 +816,93 @@ 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_prev_target_ph' => 'E.g., 150',
'bud_prev_transfer_to' => 'To',
// Budget : Tricount / Advances
'bud_adv_title' => 'Advances & Family Tricount',
'bud_adv_add' => 'Add an Advance',
'bud_adv_edit' => 'Edit Advance',
'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?',
// Budget : Provisions & Optimization
'budget_provisions_title' => 'Provisions & Cashflow Optimization',
'btn_optimize_cashflow' => 'Optimize My Savings',
'add_new_provision' => 'Anticipate an upcoming major expense',
'provision_label' => 'Expense description',
'provision_placeholder_wood' => 'E.g., Firewood order, Car service...',
'amount' => 'Amount',
'expected_date' => 'Expected date',
'upcoming_provisions_list' => 'Scheduled expenses',
'optimization_assistant_title' => 'Fortnightly Interest Optimization Assistant',
'success_add_provision' => 'Forecasted expense added successfully!',
'no_provisions' => 'No forecasted expenses registered at the moment.',
'savings_this_month' => 'Total savings to allocate this month',
'calculate' => 'Calculate optimal strategy',
'date_past_warning' => 'The selected date has already passed!',
// ========================================== // ==========================================
// GIFTS // 8. GIFTS
// ========================================== // ==========================================
'gift_page_title' => 'HouseHub - Gift list', 'gift_page_title' => 'Gift List',
'gift_occ_tio' => 'Tió', 'gift_main_title' => 'Gifts of %s',
'gift_occ_noel' => 'Christmas', 'gift_filter_all_children' => 'All children',
'gift_occ_rois' => 'Three Kings', 'gift_filter_all_adults' => 'All adults',
'gift_occ_anniv' => 'Birthday', 'gift_add_gift' => 'Add',
'gift_occ_sant' => 'Name day', 'gift_empty_state_no_gifts' => 'No gifts recorded for this occasion.',
'gift_main_title' => 'Gift list %s', 'gift_empty_state_no_filter' => 'No gifts match your filters.',
'gift_aria_change_view' => 'Change view', 'gift_paid_by' => 'Paid by %s',
'gift_view_nadal' => 'Christmas', 'gift_liquidations' => 'Balance & Refunds',
'gift_view_anniv' => 'Birthdays', 'gift_no_debt' => 'Everything is settled, no debts!',
'gift_view_by_party' => 'View by occasion', 'gift_owes' => 'owes',
'gift_no_gifts' => 'No gifts recorded for %s in this view.', 'gift_view_matrix' => 'View detailed matrix',
'gift_add_gift' => 'Add a gift', 'gift_debtor' => 'Debtor',
'gift_paid_by' => 'paid by %s', 'gift_creditor' => 'Creditor',
'gift_summary_title' => 'Budget summary', 'gift_col_adult' => 'On behalf of',
'gift_col_adult' => 'Adult', 'gift_modal_payer' => 'Paid by',
'gift_col_child' => 'Child', 'gift_modal_gift_name' => 'Gift description',
'gift_col_party' => 'Occasion', 'gift_modal_price' => 'Estimated / paid price (€)',
'gift_debtor' => 'Debtor', 'gift_modal_link' => 'Product link (URL)',
'gift_creditor' => 'Creditor', 'gift_modal_title_add' => 'Add for %s',
'gift_liquidations' => 'Settlement', 'gift_modal_title_edit' => 'Edit gift',
'gift_no_debt' => 'No outstanding debts.', 'gift_confirm_delete' => 'Are you sure you want to delete this gift?',
'gift_owes' => 'owes', 'gift_settings_title' => 'Occasions Settings',
'gift_to' => 'to', 'gift_settings_add_title' => '+ Add an occasion',
'gift_detailed_list' => 'Detailed gift list', 'gift_settings_name_placeholder' => 'Name (e.g. Valentine\'s Day)',
'gift_col_gift' => 'Gift', 'gift_settings_date_placeholder' => 'MM-DD',
'gift_col_link' => 'Link', 'gift_settings_date_help' => 'Date (MM-DD) is optional. Leave empty for a floating date.',
'gift_modal_title_add' => 'Add a gift for %s', 'gift_settings_empty' => 'No occasion configured.',
'gift_modal_title_edit' => 'Edit gift', 'gift_settings_active' => 'Active',
'gift_modal_payer' => 'Paid by', 'gift_settings' => 'Settings',
'gift_modal_gift_name' => 'Gift name', 'gift_tab_occasions' => 'Occasions & Events',
'gift_modal_ph_name' => 'e.g. Lego Star Wars', 'gift_tab_proches' => 'Relatives (Outside household)',
'gift_modal_price' => 'Price (€)', 'gift_settings_icon' => 'Icon',
'gift_modal_link' => 'Link (optional)', 'gift_settings_date' => 'Date (MM-DD)',
'gift_confirm_delete' => 'Are you sure you want to delete this gift?', 'gift_settings_add_occ' => 'Add an occasion',
'gift_filter_all_children' => 'All children', 'gift_settings_add_proche' => 'Add a relative',
'gift_filter_all_adults' => 'All adults', 'gift_role_proche_adulte' => 'Adult (Uncle, friend...)',
'gift_empty_state_no_gifts' => 'No gifts yet.', 'gift_role_proche_enfant' => 'Child (Nephew, niece...)',
'gift_empty_state_no_filter' => 'No gifts match the filter.', 'gift_ph_proche_name' => 'First name',
'gift_view_matrix' => 'View detailed matrix', 'gift_role_label' => 'Role',
'gift_settings_saved' => 'Settings saved!',
'gift_settings_edit_occ' => 'Edit occasion',
'gift_settings_edit_proche' => 'Edit relative',
// ========================================== // ==========================================
// GARAGE MANAGER // 9. GARAGE
// ========================================== // ==========================================
'menu_garage' => 'Garage',
'garage_page_title' => 'HouseHub - Garage', 'garage_page_title' => 'HouseHub - Garage',
'garage_stat_vehicles' => 'Vehicles', 'garage_stat_vehicles' => 'Vehicles',
'garage_stat_maintenances' => 'Services', 'garage_stat_maintenances' => 'Services',
@@ -584,4 +951,79 @@ return [
'garage_change_photo' => 'Change photo', 'garage_change_photo' => 'Change photo',
'garage_new_photo' => 'New photo', 'garage_new_photo' => 'New photo',
'garage_update_photo' => 'Update', 'garage_update_photo' => 'Update',
];
// ==========================================
// 10. LISTS & GROCERIES
// ==========================================
'liste_placeholder_add' => 'Add an item...',
'liste_btn_add' => 'Add',
'liste_btn_uncheck_all' => 'Uncheck all',
'liste_btn_delete_picked' => 'Delete checked items',
'liste_btn_clear_all' => 'Clear list',
'liste_subtitle' => '{pending} to do · {cart} checked',
'liste_added' => 'Added',
'liste_updated' => 'Updated',
'liste_deleted' => 'Deleted',
'liste_already_in' => 'Already in the list',
'liste_confirm_clear' => 'Clear the entire list?',
'liste_confirm_delete_list' => 'Delete this list and all its items?',
'liste_history_title' => 'Recently used — click to add',
'liste_new_list' => 'New list',
'liste_list_name_placeholder'=> 'List name...',
'liste_create' => 'Create',
'liste_rename_list' => 'Rename',
'liste_new_name' => 'New name:',
'liste_list_deleted' => 'List deleted',
'liste_list_created' => 'List created',
'liste_list_renamed' => 'List renamed',
'liste_edit_placeholder' => 'Edit item:',
'liste_empty_state' => 'Create your first list using the + button above.',
'liste_settings_title' => 'Lists — history',
'liste_settings_intro' => 'Maximum number of items remembered in history (shared across lists).',
'liste_settings_history_max' => 'History size',
'liste_settings_history_hint'=> 'Between 1 and 50.',
'groceries_settings_saved' => 'Preferences saved.',
'groceries_settings_error' => 'Could not save:',
'groceries_err_duplicate' => 'This product is already on the list.',
// --- MODULE MEALS ---
'menu_meals' => 'Meals',
'mod_meals_name' => 'Meal Plan',
'mod_meals_desc' => 'Plan weekly meals for the whole family.',
'meals_board_title' => 'Meal Planner',
'meals_copy_lunch' => 'Copy entire lunch',
'meals_copy_dinner' => 'Copy entire dinner',
'meal_lunch' => 'Lunch',
'meal_dinner' => 'Dinner',
'btn_apply_all' => 'All',
'btn_copy_to' => 'Copy to...',
'modal_copy_title' => 'Copy to...',
'source_individual' => 'Individual meal',
'source_row' => 'Full meal',
'week_current' => 'Current week',
'week_next' => 'Next week',
'meals_no_members' => 'Please configure family members in the settings.',
'day_full_mon' => 'Monday', 'day_full_tue' => 'Tuesday', 'day_full_wed' => 'Wednesday',
'day_full_thu' => 'Thursday', 'day_full_fri' => 'Friday', 'day_full_sat' => 'Saturday', 'day_full_sun' => 'Sunday',
'meal_title' => 'Meal Planner',
'meal_edit_title' => 'Edit meal',
'meal_label_name' => 'Planned menu',
'meal_ph_name' => 'E.g. Homemade lasagna',
'meal_week' => 'Week',
'meal_empty_slot' => 'To be defined...',
'meal_current_week' => 'Current week',
'meal_saved' => 'Meal saved!',
'meal_btn_import_pdf' => 'Import Menu PDF',
'meal_import_title' => 'Import canteen menu',
'meal_import_child' => 'For which child?',
'meal_select_child' => 'Select child',
'meal_pdf_file' => 'PDF File (Convivio)',
'meal_drop_pdf' => 'Click to add PDF',
'meal_import_success' => 'Menus imported successfully!',
'mod_food_name' => 'Kitchen & Groceries',
'food_tab_meals' => 'Weekly Menu',
'food_tab_list' => 'Shopping List',
'menu_food' => 'Food',
'mod_food_desc' => 'Manage your weekly menus and shared shopping lists.',
'cta_food' => 'Cook up',
];
+564 -143
View File
@@ -1,38 +1,30 @@
<?php <?php
// Dictionnaire Français // Dictionnaire Français - HouseHub (Optimisé & Dédupliqué)
return [ return [
// ========================================== // ==========================================
// TEST AUTO // 1. GLOBAL, NAVIGATION & ACTIONS
// ==========================================
'test_running' => 'Tests en cours...',
'test_passed' => 'Test réussi',
'test_failed' => 'Test échoué',
'tests_title' => 'Laboratoire de Tests',
'tests_run_budget' => 'Tester le Budget',
'tests_run_all' => 'Lancer tous les tests',
'tests_copy_report' => 'Copier le rapport',
'tests_waiting' => 'En attente du lancement...',
'tests_report_copied' => 'Rapport copié dans le presse-papier !',
'tests_live_preview' => 'Aperçu en direct',
// ==========================================
// GLOBAL & NAVIGATION
// ========================================== // ==========================================
'menu_home' => 'Accueil', 'menu_home' => 'Accueil',
'menu_calendar' => 'Calendrier', 'menu_calendar' => 'Calendrier',
'menu_budget' => 'Budget', 'menu_budget' => 'Budget',
'menu_holidays' => 'Voyages', 'menu_holidays' => 'Voyages',
'menu_gifts' => 'Cadeaux', 'menu_gifts' => 'Cadeaux',
'menu_garage' => 'Garage',
'menu_memo' => 'Notes',
'menu_todo' => 'Todo',
'menu_liste' => 'Listes',
'menu_groceries' => 'Courses',
'menu_calendar_ios' => 'Calendrier iOS',
'menu_printvault' => 'PrintVault',
'menu_planka' => 'Planka',
'nav_home' => 'Accueil', 'nav_home' => 'Accueil',
'nav_calendar' => 'Calendrier', 'nav_calendar' => 'Calendrier',
'nav_budget' => 'Budget', 'nav_budget' => 'Budget',
'nav_holidays' => 'Voyages', 'nav_holidays' => 'Voyages',
'nav_gifts' => 'Cadeaux', 'nav_gifts' => 'Cadeaux',
'aria_menu' => 'Menu mobile', 'aria_menu' => 'Menu mobile',
// Boutons et actions globales
'actions' => 'Actions', 'actions' => 'Actions',
'edit' => 'Modifier', 'edit' => 'Modifier',
'delete' => 'Supprimer', 'delete' => 'Supprimer',
@@ -42,28 +34,33 @@ return [
'btn_cancel' => 'Annuler', 'btn_cancel' => 'Annuler',
'btn_delete' => 'Supprimer', 'btn_delete' => 'Supprimer',
'btn_close' => 'Fermer', 'btn_close' => 'Fermer',
'btn_add' => 'Ajouter',
'btn_back' => '◀ Retour', 'btn_back' => '◀ Retour',
'btn_edit_bases' => '⚙️ Modifier les bases', 'btn_edit_bases' => '⚙️ Modifier les bases',
'btn_edit' => 'Modifier', 'btn_edit' => 'Modifier',
'error_occured' => 'Une erreur est survenue.',
// Mots génériques & Erreurs
'error' => 'Erreur.', 'error' => 'Erreur.',
'error_occured' => 'Une erreur est survenue.',
'error_network' => 'Erreur réseau',
'error_generic' => 'Une erreur est survenue',
'confirm_delete' => 'Es-tu sûr de vouloir supprimer cet élément ?', 'confirm_delete' => 'Es-tu sûr de vouloir supprimer cet élément ?',
'loading' => 'Chargement...',
'date' => 'Date',
'settings' => 'Paramètres',
// Mois & Jours // Mois & Jours
'month_01' => 'Janvier', 'month_02' => 'Février', 'month_03' => 'Mars', 'month_01' => 'Janvier', 'month_02' => 'Février', 'month_03' => 'Mars', 'month_04' => 'Avril', 'month_05' => 'Mai', 'month_06' => 'Juin',
'month_04' => 'Avril', 'month_05' => 'Mai', 'month_06' => 'Juin', 'month_07' => 'Juillet', 'month_08' => 'Août', 'month_09' => 'Septembre', 'month_10' => 'Octobre', 'month_11' => 'Novembre', 'month_12' => 'Décembre',
'month_07' => 'Juillet', 'month_08' => 'Août', 'month_09' => 'Septembre', 'month_jan' => 'Janvier', 'month_feb' => 'Février', 'month_mar' => 'Mars', 'month_apr' => 'Avril', 'month_may' => 'Mai', 'month_jun' => 'Juin',
'month_10' => 'Octobre', 'month_11' => 'Novembre','month_12' => 'Décembre', 'month_jul' => 'Juillet', 'month_aug' => 'Août', 'month_sep' => 'Septembre', 'month_oct' => 'Octobre', 'month_nov' => 'Novembre', 'month_dec' => 'Décembre',
'day_mon' => 'Lun', 'day_tue' => 'Mar', 'day_wed' => 'Mer', 'day_mon' => 'Lun', 'day_tue' => 'Mar', 'day_wed' => 'Mer', 'day_thu' => 'Jeu', 'day_fri' => 'Ven', 'day_sat' => 'Sam', 'day_sun' => 'Dim',
'day_thu' => 'Jeu', 'day_fri' => 'Ven', 'day_sat' => 'Sam', 'day_sun' => 'Dim',
// ========================================== // ==========================================
// AUTHENTIFICATION / LOGIN // 2. AUTHENTIFICATION / LOGIN
// ========================================== // ==========================================
'login_title' => 'Connexion - HouseHub', 'login_title' => 'Connexion - HouseHub',
'login_header' => 'Connexion', 'login_header' => 'Connexion',
'login_subtitle' => 'Veuillez vous identifier pour continuer',
'label_username' => 'Identifiant', 'label_username' => 'Identifiant',
'label_password' => 'Mot de passe', 'label_password' => 'Mot de passe',
'placeholder_username' => 'ex: pacha', 'placeholder_username' => 'ex: pacha',
@@ -71,18 +68,19 @@ return [
'btn_login_submit' => 'Se connecter', 'btn_login_submit' => 'Se connecter',
'logout' => 'Déconnexion', 'logout' => 'Déconnexion',
'btn_logout' => 'Déconnexion', 'btn_logout' => 'Déconnexion',
'login_remember_me' => 'Se souvenir de moi',
'error_missing_fields' => 'Champs obligatoires.', 'error_missing_fields' => 'Champs obligatoires.',
'error_invalid_credentials' => 'Identifiants incorrects.', 'error_invalid_credentials' => 'Identifiants incorrects.',
'login_subtitle' => 'Veuillez vous identifier pour continuer',
// ========================================== // ==========================================
// ACCUEIL / INDEX // 3. ACCUEIL & DASHBOARD
// ========================================== // ==========================================
'home_title' => 'HouseHub - Accueil', 'home_title' => 'HouseHub - Accueil',
'home_welcome' => 'Bienvenue sur HouseHub', 'home_welcome' => 'Bienvenue sur HouseHub',
'home_subtitle' => "Centre de contrôle de l'organisation familiale.", 'home_subtitle' => "Centre de contrôle de l'organisation familiale.",
'home_logged_as' => 'Connecté en tant que', 'home_logged_as' => 'Connecté en tant que',
'home_modules_title' => 'Modules', 'home_modules_title' => 'Modules',
'mod_calendar_name' => 'Calendrier Familial', 'mod_calendar_name' => 'Calendrier Familial',
'mod_calendar_desc' => "Gérez les congés, les modes de garde et l'organisation hebdomadaire.", 'mod_calendar_desc' => "Gérez les congés, les modes de garde et l'organisation hebdomadaire.",
'mod_holidays_name' => 'Vacances', 'mod_holidays_name' => 'Vacances',
@@ -93,19 +91,154 @@ return [
'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.', 'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.',
'mod_garage_name' => 'Garage', 'mod_garage_name' => 'Garage',
'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.', 'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Vos mémos, recettes, instructions et infos diverses avec Markdown et pièces jointes.',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Gérez vos tâches, listes de courses et rappels en famille.',
'mod_liste_name' => 'Listes',
'mod_liste_desc' => 'Listes partagées nommées — courses, bricolage, pharmacie… Autant de listes que nécessaire.',
'mod_groceries_name' => 'Courses',
'mod_groceries_desc' => 'Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.',
'mod_calendar_ios_name' => 'Calendrier iOS',
'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).',
'mod_printvault_name'=> 'PrintVault',
'mod_printvault_desc'=> 'Gérez vos fichiers d\'impression 3D — STL, 3MF, GCode avec viewer interactif.',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Gérez vos projets en mode Kanban avec Planka.',
'cta_open' => 'Ouvrir', 'cta_open' => 'Ouvrir',
'cta_explore' => 'Explorer', 'cta_explore' => 'Explorer',
'cta_view_lists' => 'Voir les listes', 'cta_view_lists' => 'Déballer',
'cta_manage' => 'Gérer', 'cta_manage' => 'Gérer',
'cta_service' => 'Réviser',
'cta_jot' => 'Griffonner',
'cta_check' => 'Cocher',
'cta_cart' => 'Faire les courses',
'cta_sync' => 'Synchroniser',
'cta_print' => 'Imprimer',
// ========================================== // ==========================================
// FAMILY CALENDAR // 4. PARAMÈTRES (SETTINGS)
// ==========================================
'set_title_page' => '⚙️ Paramètres',
'set_title_lang' => '🌐 Langue / Language',
'set_title_modules' => '🧩 Modules actifs',
'set_desc_modules' => 'Choisissez les modules visibles dans la navigation.',
'set_title_ios' => '📱 Intégration Calendrier iOS (CalDAV)',
'set_desc_ios' => 'Configurez ici votre calendrier iCloud pour synchroniser les événements.',
'set_label_apple_id' => 'Identifiant Apple (email iCloud)',
'set_label_apple_pwd' => 'Mot de passe d\'app Apple',
'set_label_apple_url' => 'URL du calendrier CalDAV',
'set_btn_test_ios' => 'Tester la connexion',
'set_btn_disconnect_ios' => 'Déconnecter',
'set_title_bg' => '🖼️ Image d\'accueil',
'set_desc_bg' => 'Photo de fond affichée sur la page d\'accueil.',
'set_badge_custom_bg' => 'Image personnalisée',
'set_label_new_bg' => 'Nouvelle image (jpg, png, webp — max 10 Mo)',
'set_btn_reset_bg' => '🔄 Remettre l\'image par défaut',
'set_confirm_reset_bg' => 'Revenir à l\'image par défaut ?',
'set_title_profile' => '👤 Mon profil',
'set_label_username' => 'Nom d\'utilisateur',
'set_label_display_name' => 'Prénom / Pseudo affiché',
'set_label_profile_color' => 'Couleur thématique de mon profil',
'set_desc_profile_color' => 'Cette couleur sera utilisée pour vos grilles de budget, calendriers et indicateurs personnels.',
'set_title_pwd' => '🔒 Changer le mot de passe',
'set_label_cur_pwd' => 'Mot de passe actuel',
'set_label_new_pwd' => 'Nouveau mot de passe',
'set_label_confirm_pwd' => 'Confirmer',
'set_btn_edit' => 'Modifier',
'set_title_family' => '🏠 Mon espace familial',
'set_label_family_name' => 'Nom de l\'espace',
'set_desc_invite' => 'Code d\'invitation — partagez-le pour inviter quelqu\'un',
'set_title_copy' => 'Cliquer pour copier',
'set_msg_copied' => '✓ Copié !',
'set_btn_new_code' => '🔄 Nouveau code',
'set_confirm_new_code' => 'Regénérer le code ? L\'ancien sera invalidé.',
'set_desc_register_link' => 'Lien d\'inscription :',
'set_title_members' => 'Membres',
'set_badge_admin' => 'Admin',
'set_title_attached' => '🧸 Profils rattachés',
'set_desc_attached' => 'Membres gérés par le foyer n\'ayant pas d\'accès direct.',
'set_empty_attached' => 'Aucun profil rattaché.',
'set_link_admin' => '→ Panneau d\'administration',
'set_kids_title' => 'Enfants du foyer',
'set_kids_desc' => 'Gérez la liste des enfants pour les associer à l\'épargne, aux cadeaux, etc.',
'set_add_kid_placeholder' => 'Prénom de l\'enfant',
'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',
// Labels Profils
'label_firstname' => 'Prénom',
'placeholder_firstname_ex' => 'Ex: Pol, Carole...',
'label_role' => 'Rôle',
'role_child' => '👶 Enfant',
'role_child_icon' => '👶 Enfant',
'role_helper' => '💼 Intervenant',
'role_helper_icon' => '💼 Intervenant',
'role_relative' => '👵 Proche',
'role_relative_icon' => '👵 Proche',
'role_unknown' => 'Inconnu',
'label_color' => 'Couleur',
'change_color' => 'Changer la couleur',
'confirm_delete_profile' => 'Supprimer définitivement ce profil ?',
// Messages Settings
'error_csrf' => 'Session invalide (CSRF). Rechargez la page.',
'set_err_min_module' => 'Vous devez garder au moins un module actif.',
'set_success_modules' => 'Modules mis à jour avec succès.',
'set_success_lang' => 'Langue mise à jour.',
'set_err_empty_name' => 'Le prénom ne peut pas être vide.',
'set_success_profile' => 'Profil mis à jour avec succès.',
'set_err_wrong_pwd' => 'Mot de passe actuel incorrect.',
'set_err_pwd_length' => 'Le nouveau mot de passe doit faire au moins 6 caractères.',
'set_err_pwd_match' => 'Les mots de passe ne correspondent pas.',
'set_success_pwd' => 'Mot de passe modifié avec succès.',
'set_success_family' => 'Nom de l\'espace mis à jour.',
'set_success_invite' => 'Nouveau code d\'invitation généré.',
'set_err_bg_format' => 'Format non supporté (jpg, png, webp).',
'set_success_bg' => 'Image d\'accueil mise à jour.',
'set_err_upload' => 'Erreur lors de l\'upload.',
'set_success_bg_reset' => 'Image d\'accueil réinitialisée.',
'set_err_db_unavailable' => 'Base famille indisponible.',
'set_err_ios_missing' => 'Merci de renseigner identifiant iCloud, mot de passe d\'app et URL CalDAV.',
'set_success_ios' => 'Connexion calendrier iOS enregistrée.',
'set_success_ios_auto' => ' URL du calendrier détectée automatiquement.',
'set_err_ios_save' => 'Impossible d\'enregistrer la connexion iOS : ',
'set_err_ios_none' => 'Aucune connexion iOS configurée.',
'set_success_ios_test' => 'Connexion iCloud CalDAV valide (HTTP %s).',
'set_err_ios_test' => 'Test connexion échoué (HTTP %s). Vérifiez vos identifiants.',
'set_err_ios_test_fail' => 'Test connexion impossible : ',
'set_success_ios_del' => 'Connexion iOS supprimée.',
'set_success_profile_add' => 'Profil de %s ajouté avec succès.',
'set_err_name_required' => 'Le prénom est obligatoire.',
'set_success_profile_del' => 'Profil supprimé.',
'set_success_color' => 'Couleur mise à jour !',
'set_success_color_profile' => 'Couleur du profil mise à jour.',
'set_success_color_update' => 'Couleur mise à jour !',
'color_updated' => 'Couleur mise à jour !',
'settings_profile_added' => 'Profil de %s ajouté avec succès.',
'settings_err_name_required' => 'Le prénom est obligatoire.',
'settings_attached_profiles_title' => 'Profils rattachés',
'settings_attached_profiles_desc' => 'Membres gérés par le foyer (Enfants, Nounous, Proches) n\'ayant pas d\'accès direct.',
'settings_no_attached_profiles' => 'Aucun profil rattaché.',
'placeholder_apple_id' => 'nom@icloud.com',
'placeholder_apple_pwd' => 'xxxx-xxxx-xxxx-xxxx',
// ==========================================
// 5. CALENDRIER FAMILIAL
// ========================================== // ==========================================
'fc_page_title' => 'HouseHub - Calendrier', 'fc_page_title' => 'HouseHub - Calendrier',
'fc_main_header' => 'Calendrier Familial', 'fc_main_header' => 'Calendrier Familial',
'fc_btn_correct_balances' => 'Corriger les soldes', 'fc_btn_correct_balances' => 'Corriger les soldes',
'fc_btn_school_holidays' => 'Vacances scolaires', 'fc_btn_school_holidays' => 'Vacances scolaires',
'fc_modal_holidays_title' => 'Vacances Scolaires (Zone C)', 'fc_modal_holidays_title' => 'Vacances Scolaires',
'fc_modal_snap_title' => 'Ajuster un solde', 'fc_modal_snap_title' => 'Ajuster un solde',
'fc_col_period' => 'Période', 'fc_col_period' => 'Période',
'fc_col_from' => 'Du', 'fc_col_from' => 'Du',
@@ -124,6 +257,7 @@ return [
'fc_weekly_planning' => 'Planning hebdomadaire', 'fc_weekly_planning' => 'Planning hebdomadaire',
'fc_col_month' => 'Mois', 'fc_col_month' => 'Mois',
'fc_col_week_short' => 'Sem.', 'fc_col_week_short' => 'Sem.',
'fc_col_week' => 'Semaine',
'fc_col_av' => 'Av.', 'fc_col_av' => 'Av.',
'fc_col_use' => 'Use', 'fc_col_use' => 'Use',
'fc_legend_title' => 'Légende', 'fc_legend_title' => 'Légende',
@@ -134,14 +268,96 @@ return [
'fc_unit_days' => 'jours', 'fc_unit_days' => 'jours',
'fc_menu_carole' => 'Congés Carole', 'fc_menu_carole' => 'Congés Carole',
'fc_clear' => 'Effacer', 'fc_clear' => 'Effacer',
'fc_menu_kids_leaves' => 'Congés Enfants', 'fc_menu_kids_leaves' => 'Congés Parents',
'fc_err_save_snap' => 'Erreur lors de la sauvegarde du correctif.', 'fc_err_save_snap' => 'Erreur lors de la sauvegarde du correctif.',
'fc_err_fetch_gov' => 'Erreur lors de la connexion à l\'API gouvernementale.', 'fc_err_fetch_gov' => 'Erreur lors de la connexion à l\'API gouvernementale.',
'fc_err_no_data_gov' => 'Aucune donnée trouvée sur l\'API du gouvernement pour cette année.', 'fc_err_no_data_gov' => 'Aucune donnée trouvée sur l\'API du gouvernement pour cette année.',
'fc_err_action' => 'Erreur lors de l\'action : ', 'fc_err_action' => 'Erreur lors de l\'action : ',
'fc_err_select_leave' => 'Veuillez sélectionner un type de congé.',
'fc_today_short' => 'Auj.', 'fc_today_short' => 'Auj.',
'fc_today_title' => "Aujourd'hui", 'fc_today_title' => "Aujourd'hui",
'fc_tab_foyer' => '🏡 Foyer',
'fc_tab_members' => '👥 Membres & Droits',
'fc_select_member' => 'Sélectionner un membre :',
'fc_role_adult' => 'Adulte',
'fc_role_child' => 'Enfant',
'fc_role_helper' => 'Intervenant',
'fc_role_unknown' => 'Inconnu',
'fc_care_modes_title' => 'Modes de Garde Par Défaut',
'fc_care_modes_desc' => 'Quels modes de garde s\'appliquent à cet enfant ?',
'fc_no_care_mode' => 'Aucun mode de garde configuré dans le foyer.',
'fc_matrix_title' => 'Matrice des Congés',
'fc_matrix_desc' => 'Définissez les quotas et la date de renouvellement.',
'fc_matrix_code' => 'Code',
'fc_matrix_date' => 'Date Reset',
'fc_matrix_empty' => 'Aucun compteur défini.',
'fc_matrix_saved' => 'Matrice enregistrée !',
'fc_child_saved' => 'Sauvegardé !',
'fc_modal_settings_title' => 'Paramètres du calendrier',
'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.',
'fc_care_modes_label' => 'Modes de garde',
'fc_add_mode_placeholder' => 'Ex: Nounou, Crèche...',
'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',
'fc_settings_updated' => 'Paramètres mis à jour avec succès !',
'fc_first_day' => 'Premier jour de la semaine',
'fc_helper_recap' => 'Récapitulatif Intervenant',
'fc_no_helper_leaves' => 'Aucun congé posé ce mois-ci.',
'fc_col_date' => 'Date',
'fc_col_type' => 'Type',
'fc_col_duration' => 'Durée',
'fc_full_year' => 'Année complète',
'fc_custom_period' => 'Période spécifique...',
'fc_worked_days' => 'Travaillés (Est.)',
'fc_tab_display' => '🖥️ Affichage',
'fc_display_title' => 'Paramètres d\'affichage',
'fc_display_desc' => 'Personnalisez l\'apparence et le comportement par défaut de votre calendrier.',
'fc_default_view' => 'Vue par défaut',
'fc_working_hours' => 'Horaires de garde (Défaut)',
'fc_working_hours_desc' => 'Exemple : 08:30-18:00',
'fc_error' => 'Erreur : ',
'fc_leave_catalog_title' => 'Catalogue des congés',
'fc_leave_catalog_desc' => 'Définissez les types de congés possibles pour toute la famille.',
'fc_add_leave_type' => 'Ajouter un type de congé',
'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.',
'fc_leave_obsolete' => 'Obsolète',
'fc_leave_code_placeholder' => 'Code...',
'fc_leave_method_fixed' => 'Fixe',
'fc_leave_method_accumulated'=> 'Cumul',
'fc_date_format_placeholder'=> 'JJ/MM',
'fc_date_format_title' => 'Format JJ/MM',
'fc_table_code' => 'Code',
'fc_table_method' => 'Méthode',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renouv.',
'fc_label_carry_over' => 'Dont jours reportés (Périssables) ⚠️',
'fc_carry_over_note' => 'Partie du solde global qui expirera à la date butoir définie.',
'fc_carry_max' => 'Max Reportable',
'fc_carry_deadline' => 'Mois Expiration',
'fc_deadline_none' => 'Aucune',
'fc_allowance_help' => 'Fixe: Quota annuel. Graduel: Ajout par mois.',
'fc_carry_max_short' => 'Report max',
'fc_edit_leave' => '✏️ Modifier :',
'fc_not_modifiable' => 'Non modifiable',
'fc_add_leave' => '+ Ajouter',
'fc_err_code_label' => 'Le code et le libellé sont obligatoires.',
'fc_catalog_updated' => 'Catalogue mis à jour !',
'fc_balance_corrected' => 'Solde corrigé !',
'fc_no_leave_configured' => 'Aucun congé configuré.',
'fc_confirm_del_catalog' => 'Supprimer définitivement le congé %s du catalogue ? Cela le retirera également des membres qui l\'utilisent.',
'fc_confirm_del_member_leave'=> 'Voulez-vous vraiment retirer ce congé pour ce membre ?',
// Légendes Calendrier
'leg_presence' => 'Présence Pep', 'leg_presence' => 'Présence Pep',
'leg_school_holidays' => 'Vacances Scolaires', 'leg_school_holidays' => 'Vacances Scolaires',
'leg_public_holiday' => 'Férié', 'leg_public_holiday' => 'Férié',
@@ -149,7 +365,10 @@ return [
'leg_extra_off' => 'Extra Off', 'leg_extra_off' => 'Extra Off',
'leg_centre' => 'Centre', 'leg_centre' => 'Centre',
'leg_avis' => 'Avis', 'leg_avis' => 'Avis',
'leg_pep_sick' => 'Pep Malade', 'fc_menu_sick' => 'Malade',
'care_nounou' => 'Nounou',
'care_centre' => 'Centre de loisirs',
'care_avis' => 'Avis',
'vac_toussaint' => 'Vacances de la Toussaint', 'vac_toussaint' => 'Vacances de la Toussaint',
'vac_noel' => 'Vacances de Noël', 'vac_noel' => 'Vacances de Noël',
@@ -159,12 +378,11 @@ return [
'vac_ete' => "Vacances d'Été", 'vac_ete' => "Vacances d'Été",
'btn_off' => 'Off', 'btn_off' => 'Off',
'btn_extra' => 'Extra', 'btn_extra' => 'Extra',
'fc_alert_burn_days' => '⚠️ Risque de perte : %s j à poser avant la fin de cycle',
'fc_alert_burn_days' => '⚠️ Risque de perte : %s j à poser avant le %s', 'fc_alert_burn_jra' => '⚠️ Alerte : Jours reportés expirent à la fin de ce mois !',
'fc_alert_burn_jra' => '⚠️ JRA : Seuls 2j sont reportables après février !',
// ========================================== // ==========================================
// HOLIDAYS (VOYAGES) // 6. VOYAGES (HOLIDAYS)
// ========================================== // ==========================================
'hdl_title_list' => 'HouseHub - Mes Vacances', 'hdl_title_list' => 'HouseHub - Mes Vacances',
'hdl_title_detail' => 'HouseHub - Détail du voyage', 'hdl_title_detail' => 'HouseHub - Détail du voyage',
@@ -239,24 +457,23 @@ return [
'hdl_to_place' => 'À placer', 'hdl_to_place' => 'À placer',
'hdl_from' => 'Du', 'hdl_from' => 'Du',
'hdl_to' => 'Au', 'hdl_to' => 'Au',
'hdl_btn_add_step' => 'Ajouter une étape',
'hdl_quick_edit_title' => 'Modification rapide', // Météo et spécifiques Holidays
'hdl_paid' => 'Payé', 'weather_forecast' => 'Prévisions météo',
'weather_forecast' => 'Prévisions météo', 'weather_not_available' => 'Météo indisponible',
'weather_not_available' => 'Météo indisponible', 'temp_max' => 'Temp. Max',
'temp_max' => 'Temp. Max', 'weather_loading' => 'Chargement météo...',
'weather_loading' => 'Chargement météo...', 'weather_error' => 'Météo indisponible',
'weather_error' => 'Météo indisponible', 'weather_sunny' => 'Ensoleillé',
'weather_sunny' => 'Ensoleillé', 'weather_cloudy' => 'Nuageux',
'weather_cloudy' => 'Nuageux', 'weather_rainy' => 'Pluvieux',
'weather_rainy' => 'Pluvieux', 'weather_snowy' => 'Neigeux',
'weather_snowy' => 'Neigeux', 'weather_historical' => 'Basé sur l\'historique (estimation)',
'weather_historical' => 'Basé sur l\'historique (estimation)', 'err_invalid_holiday_id' => 'ID de voyage invalide.',
'err_invalid_holiday_id' => 'ID de voyage invalide.', 'hdl_default_exp_name' => 'Dépense',
'hdl_default_exp_name' => 'Dépense', 'hdl_select_beneficiary' => '-- Sélectionner --',
'hdl_select_beneficiary' => '-- Sélectionner --',
// Anciennes clés Holidays non préfixées (conservées pour compatibilité si utilisées) // Anciennes clés (conservées au cas où)
'outbound_trip' => 'Aller', 'outbound_trip' => 'Aller',
'return_trip' => 'Retour', 'return_trip' => 'Retour',
'period' => 'Période', 'period' => 'Période',
@@ -309,7 +526,7 @@ return [
'to_place' => '📥 À Placer', 'to_place' => '📥 À Placer',
// ========================================== // ==========================================
// BUDGET (GLOBAL & CATEGORIES) // 7. BUDGET (GLOBAL, SUIVI, PREV, EPARGNE, PROVISIONS)
// ========================================== // ==========================================
'budget_page_title' => 'HouseHub - Budget', 'budget_page_title' => 'HouseHub - Budget',
'budget_main_header' => 'Gestion du Budget', 'budget_main_header' => 'Gestion du Budget',
@@ -317,9 +534,16 @@ return [
'budget_tab_prev' => 'Budget 2026', 'budget_tab_prev' => 'Budget 2026',
'budget_tab_savings' => 'Épargne', 'budget_tab_savings' => 'Épargne',
'budget_tab_recap' => 'Récapitulatif', 'budget_tab_recap' => 'Récapitulatif',
'budget_tab_provisions' => 'Provisions',
'budget_tab_kids' => 'Enfants',
'budget_err_file_not_found' => 'Oups ! Fichier introuvable.', 'budget_err_file_not_found' => 'Oups ! Fichier introuvable.',
'budget_err_file_detail' => 'Vérifiez que le fichier views/%s.php existe bien.', 'budget_err_file_detail' => 'Vérifiez que le fichier views/%s.php existe bien.',
'budget_err_not_authorized' => 'Page non autorisée.', 'budget_err_not_authorized' => 'Page non autorisée.',
'bud_err_tech' => 'Une erreur technique est survenue.',
'bud_err_server' => 'Erreur serveur : ',
'bud_err_delete' => 'Erreur lors de la suppression.',
// Catégories (Budget)
'cat_income' => 'Revenus', 'cat_income' => 'Revenus',
'cat_fmcg' => 'Courses (FMCG)', 'cat_fmcg' => 'Courses (FMCG)',
'cat_fuel' => 'Essence', 'cat_fuel' => 'Essence',
@@ -328,7 +552,104 @@ return [
'cat_others' => 'Autres / Imprévus', 'cat_others' => 'Autres / Imprévus',
'cat_savings' => 'Épargne', 'cat_savings' => 'Épargne',
// --- BUDGET : SUIVI --- // Paramètres (Budget Settings)
'budget_settings_title' => 'Paramètres du Budget',
'bs_tab_accounts' => '💳 Comptes',
'bs_tab_categories' => '🏷️ Catégories',
'bs_tab_rules' => '🤖 Règles d\'import',
'bs_tab_salaries' => '💼 Salaires',
'bs_tab_csv' => '📥 Import CSV',
'bs_empty_accounts' => 'Aucun compte configuré.',
'bs_type_checking' => 'Courant',
'bs_type_savings' => 'Épargne',
'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 !',
'bud_settings_dyn_title' => 'Estimations Dynamiques (Enfants)',
'bud_dyn_nanny_fixed' => 'Salaire fixe Assistante Maternelle (€)',
'bud_dyn_nanny_daily' => 'Frais d\'entretien journaliers (€)',
'bud_dyn_nanny_aid' => 'Aide de l\'État (CMG) (€)',
'bud_dyn_nanny_cesu_avg' => 'Moyenne CESU versée (€)',
'bud_dyn_school_meal' => 'Prix repas Cantine (€)',
'bud_dyn_school_aftercare' => 'Prix Accueil Périscolaire soir (€)',
'bud_dyn_school_fullday' => 'Prix Accueil Journée (Mercredi/Vacances) (€)',
'bud_dyn_pattern_title' => 'Semaine type d\'école',
'bud_audit_month' => 'Mois analysé : %02d/%d',
'bud_audit_nanny_abs' => 'Absences %s déduites : %dj',
'bud_audit_child' => '👦 %s :',
'bud_audit_school_fee' => 'Cantine (%dj) + Garderie (%dj) + Centre (%dj) = %.2f€',
'bud_audit_sick' => 'Jours maladie déduits : %dj',
'bud_audit_nanny_full' => '💼 %s : Fixe (%.2f€) + Frais (%dj x %.2f€ = %.2f€) - Aide (%.2f€) - CESU (%.2f€) = %.2f€',
// CSV Mapping (Budget)
'bs_csv_title' => 'Format de l\'import CSV',
'bs_csv_desc' => 'Configurez ici les colonnes correspondantes à l\'export de votre banque.',
'bs_csv_delimiter' => 'Séparateur',
'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_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.',
// Onboarding Budget
'bs_onb_title' => 'Bienvenue ! Configurons votre budget :',
'bs_onb_desc' => 'Complétez ces étapes pour que l\'application puisse calculer votre Reste à Vivre :',
'bs_onb_cat_link' => 'Catégories',
'bs_onb_cat_desc' => 'Créez au moins une catégorie de dépenses.',
'bs_onb_acc_link' => 'Comptes Bancaires',
'bs_onb_acc_desc' => 'Ajoutez au moins un compte courant ou épargne.',
'bs_onb_sal_link' => 'Salaires',
'bs_onb_sal_desc' => 'Renseignez les rentrées d\'argent de l\'année en cours.',
'bs_onb_items_link' => 'Lignes de Budget',
'bs_onb_items_desc' => 'Rendez-vous dans l\'onglet Récapitulatif pour lister vos charges fixes (Loyer, Assurances...).',
'bs_rule_search_ph' => '🔍 Rechercher un mot-clé (ex: AUCHAN)...',
'error_loading_settings' => 'Erreur lors du chargement des paramètres',
'bud_err_delete_system_cat' => 'Impossible de supprimer une catégorie système essentielle.',
// Budget : Suivi
'bud_rem_school' => 'École (Reste estimé)', 'bud_rem_school' => 'École (Reste estimé)',
'bud_rem_fmcg' => 'Courses (Reste estimé)', 'bud_rem_fmcg' => 'Courses (Reste estimé)',
'bud_rem_fuel' => 'Essence (Reste estimé)', 'bud_rem_fuel' => 'Essence (Reste estimé)',
@@ -381,22 +702,12 @@ return [
'bud_add_title' => 'Ajouter', 'bud_add_title' => 'Ajouter',
'bud_edit_title' => 'Modifier la transaction', 'bud_edit_title' => 'Modifier la transaction',
'bud_to_define_js' => 'à définir', 'bud_to_define_js' => 'à définir',
'bud_bar_actual' => "Actuel", 'bud_bar_actual' => 'Actuel',
'bud_bar_theoretical' => "Théorique", 'bud_bar_theoretical' => 'Théorique',
'bud_bar_max_cap' => "Capacité Max", 'bud_bar_max_cap' => 'Capacité Max',
'bud_import_csv' => "Importer CSV", 'bud_select_beneficiary' => 'Choisir un revenu attendu',
'bud_csv_file' => "Sélectionnez le fichier de votre banque (.csv)",
'bud_preview' => "Prévisualiser",
'bud_validate_import' => "Veuillez vérifier et catégoriser les lignes avant validation.",
'bud_assign_month' => "Mois de gestion",
'bud_op_date' => "Date",
'bud_amount' => "Montant",
'bud_ignore' => "Ignorer (Crédit)",
'bud_to_define' => "À définir",
'bud_is_charge' => "Est une charge fixe...",
'bud_is_income' => "Est un revenu...",
// --- BUDGET : RECAP --- // Budget : Récapitulatif
'bud_recap_monthly_title' => 'Récapitulatif Mensuel', 'bud_recap_monthly_title' => 'Récapitulatif Mensuel',
'bud_recap_btn_add' => 'Ajouter un frais / revenu', 'bud_recap_btn_add' => 'Ajouter un frais / revenu',
'bud_planned_amount' => 'Montant Prévu', 'bud_planned_amount' => 'Montant Prévu',
@@ -436,39 +747,38 @@ return [
'bud_label_regularization' => 'Régularisation', 'bud_label_regularization' => 'Régularisation',
'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.',
// --- BUDGET : EPARGNE --- // Budget : Épargne
'bud_sav_add_month' => 'Saisir un mois', 'bud_sav_add_month' => 'Ajouter un mois',
'bud_sav_add_one_month' => '+1 Mois', 'bud_sav_add_one_month' => '+1 Mois',
'bud_sav_no_data' => 'Aucune donnée pour %s.', 'bud_sav_no_data' => 'Aucune donnée d\'épargne pour %s.',
'bud_sav_post_month' => 'Poste / Mois', 'bud_sav_post_month' => 'Poste / Mois',
'bud_sav_from_date' => 'Dès le %s', 'bud_sav_from_date' => 'Dès le %s',
'bud_sav_edit_modal' => 'Modifier avec Modale', 'bud_sav_edit_modal' => 'Éditer ce mois',
'bud_sav_delete_month' => 'Supprimer tout le mois', 'bud_sav_delete_month' => 'Supprimer ce mois',
'bud_sav_total_bank' => 'Total Banque', 'bud_sav_total_bank' => 'TOTAL BANQUE',
'bud_sav_extra' => 'Extra', 'bud_sav_extra' => 'NON ALLOUÉ (EXTRAS)',
'bud_sav_modal_title_add' => 'Saisir un mois', 'bud_sav_modal_title_add' => 'Ajouter un mois',
'bud_sav_modal_title_edit' => 'Modifier :', 'bud_sav_modal_title_edit' => 'Éditer',
'bud_sav_month_concerned' => 'Mois concerné', 'bud_sav_month_concerned' => 'Mois concerné',
'bud_sav_total_bank_eur' => 'Total en Banque (€)', 'bud_sav_total_bank_eur' => 'Total en Banque (€)',
'bud_sav_ventilation' => 'Ventilation', 'bud_sav_ventilation' => 'Ventilation des comptes',
'bud_sav_adj_help' => 'Utilisez l\'ajustement (+/-) pour recalculer automatiquement.', 'bud_sav_adj_help' => 'Saisissez les ajustements (+/-) pour recalculer automatiquement.',
'bud_sav_add_line' => 'Ligne', 'bud_sav_add_line' => 'Ajouter une ligne',
'bud_sav_current' => 'Actuel', 'bud_sav_current' => 'Actuel',
'bud_sav_adjust' => 'Ajust (+/-)', 'bud_sav_adjust' => 'Ajustement',
'bud_sav_new' => 'Nouveau', 'bud_sav_new' => 'Nouveau',
'bud_sav_sum_mode_title' => 'Activer l\'addition rapide', 'bud_sav_sum_mode_title' => 'Activer/Désactiver la calculatrice',
'bud_sav_selection' => 'Sélection :', 'bud_sav_selection' => 'Sélection :',
'bud_sav_ph_name' => 'Nom (ex: Vacances)', 'bud_sav_ph_name' => 'Nom du compte',
'bud_sav_saving' => 'Enregistrement...', 'bud_sav_new_line' => 'Nouvelle ligne',
'bud_err_tech' => 'Une erreur technique est survenue.', 'bud_sav_prompt_new_line' => 'Nom de la nouvelle ligne d\'épargne :',
'bud_sav_confirm_delete_month' => 'Supprimer TOUT le mois de %m pour %o ?', 'bud_sav_confirm_delete_month' => 'Voulez-vous vraiment supprimer toutes les données de %m pour %o ?',
'bud_sav_prompt_duplicate' => "Voulez-vous copier les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :", 'bud_sav_prompt_duplicate' => "Voulez-vous dupliquer les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
'bud_err_server' => 'Erreur serveur : ',
'bud_err_network_dup' => 'Erreur réseau lors de la duplication.', 'bud_err_network_dup' => 'Erreur réseau lors de la duplication.',
'bud_sav_saving' => 'Sauvegarde en cours...',
// --- BUDGET : PREVISIONNEL --- // Budget : Prévisionnel
'bud_prev_incomes' => 'Revenus', 'bud_prev_incomes' => 'Revenus',
'bud_prev_person' => 'Personne', 'bud_prev_person' => 'Personne',
'bud_prev_salary' => 'Salaire', 'bud_prev_salary' => 'Salaire',
@@ -502,57 +812,94 @@ return [
'bud_prev_err_no_history' => 'Impossible : pas d\'historique disponible pour copier.', 'bud_prev_err_no_history' => 'Impossible : pas d\'historique disponible pour copier.',
'bud_prev_confirm_copy' => "Voulez-vous copier les données de %s vers %t ?\n\n⚠️ Cela écrasera toutes les valeurs déjà présentes pour %t.", 'bud_prev_confirm_copy' => "Voulez-vous copier les données de %s vers %t ?\n\n⚠️ Cela écrasera toutes les valeurs déjà présentes pour %t.",
'bud_prev_confirm_transfers' => "Confirmer que %p a bien effectué tous ses virements pour %m ?\n\nCela mettra à jour l'Épargne automatiquement.", 'bud_prev_confirm_transfers' => "Confirmer que %p a bien effectué tous ses virements pour %m ?\n\nCela mettra à jour l'Épargne automatiquement.",
'bud_sav_modal_title_add' => 'Saisir un mois', 'bud_prev_label_name' => 'Nom',
'bud_sav_ph_name' => 'Nom (ex: Vacances)', 'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_label_name' => 'Nom', 'bud_prev_transfer_to' => 'Vers',
// Budget : Tricount / Avances
'bud_adv_title' => 'Avances & Tricount Familial',
'bud_adv_add' => 'Ajouter une avance',
'bud_adv_edit' => 'Modifier l\'avance',
'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 ?',
// Budget : Provisions & Optimisation
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
'btn_optimize_cashflow' => 'Optimiser mes Économies',
'add_new_provision' => 'Anticiper une grosse dépense à venir',
'provision_label' => 'Libellé de la dépense',
'provision_placeholder_wood' => 'Ex: Commande bois de chauffage, Révision voiture...',
'amount' => 'Montant',
'expected_date' => 'Date prévue',
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
'savings_this_month' => 'Économies globales à placer ce mois-ci',
'calculate' => 'Calculer la stratégie optimale',
'date_past_warning' => 'La date sélectionnée est déjà passée !',
// ========================================== // ==========================================
// CADEAUX (GIFTS) // 8. CADEAUX (GIFTS)
// ========================================== // ==========================================
'gift_page_title' => 'HouseHub - Liste de cadeaux', 'gift_page_title' => 'Liste des Cadeaux',
'gift_occ_tio' => 'Tió', 'gift_main_title' => 'Cadeaux de %s',
'gift_occ_noel' => 'Noël', 'gift_filter_all_children' => 'Tous les enfants',
'gift_occ_rois' => 'Rois mages', 'gift_filter_all_adults' => 'Tous les adultes',
'gift_occ_anniv' => 'Anniversaire', 'gift_add_gift' => 'Ajouter',
'gift_occ_sant' => 'Saint', 'gift_empty_state_no_gifts' => 'Aucun cadeau enregistré pour cette fête.',
'gift_main_title' => 'Liste de cadeaux %s', 'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond à vos filtres.',
'gift_aria_change_view' => 'Changer la vue', 'gift_paid_by' => 'Payé par %s',
'gift_view_nadal' => 'Noël', 'gift_liquidations' => 'Bilan & Remboursements',
'gift_view_anniv' => 'Anniversaires', 'gift_no_debt' => 'Tout est à jour, aucune dette !',
'gift_view_by_party' => 'Vue par fête', 'gift_owes' => 'doit',
'gift_no_gifts' => 'Aucun cadeau enregistré pour %s dans cette vue.', 'gift_view_matrix' => 'Voir la matrice détaillée',
'gift_add_gift' => 'Ajouter un cadeau', 'gift_debtor' => 'Débiteur',
'gift_paid_by' => 'payé par %s', 'gift_creditor' => 'Créancier',
'gift_summary_title' => 'Résumé du budget', 'gift_col_adult' => 'Pour le compte de',
'gift_col_adult' => 'Adulte', 'gift_modal_payer' => 'Payé par',
'gift_col_child' => 'Enfant', 'gift_modal_gift_name' => 'Description du cadeau',
'gift_col_party' => 'Fête', 'gift_modal_price' => 'Prix estimé / payé (€)',
'gift_debtor' => 'Débiteur', 'gift_modal_link' => 'Lien du produit (URL)',
'gift_creditor' => 'Créancier', 'gift_modal_title_add' => 'Ajouter pour %s',
'gift_liquidations' => 'Tricount', 'gift_modal_title_edit' => 'Modifier le cadeau',
'gift_no_debt' => 'Aucune dette en cours.', 'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?',
'gift_owes' => 'doit', 'gift_settings_title' => 'Configuration des Fêtes',
'gift_to' => 'à', 'gift_settings_add_title' => '+ Ajouter une occasion',
'gift_detailed_list' => 'Liste détaillée des cadeaux', 'gift_settings_name_placeholder' => 'Nom (ex: Saint Valentin)',
'gift_col_gift' => 'Cadeau', 'gift_settings_date_placeholder' => 'MM-JJ',
'gift_col_link' => 'Lien', 'gift_settings_date_help' => 'La date (MM-JJ) est optionnelle. Laissez vide pour une fête mobile.',
'gift_modal_title_add' => 'Ajouter un cadeau pour %s', 'gift_settings_empty' => 'Aucune fête configurée.',
'gift_modal_title_edit' => 'Modifier le cadeau', 'gift_settings_active' => 'Actif',
'gift_modal_payer' => 'Payé par', 'gift_settings' => 'Configuration',
'gift_modal_gift_name' => 'Nom du cadeau', 'gift_tab_occasions' => 'Fêtes & Occasions',
'gift_modal_ph_name' => 'ex: Lego Star Wars', 'gift_tab_proches' => 'Proches (Hors foyer)',
'gift_modal_price' => 'Prix (€)', 'gift_settings_icon' => 'Icône',
'gift_modal_link' => 'Lien (optionnel)', 'gift_settings_date' => 'Date (MM-JJ)',
'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?', 'gift_settings_add_occ' => 'Ajouter une fête',
'gift_filter_all_children' => 'Tous les enfants', 'gift_settings_add_proche' => 'Ajouter un proche',
'gift_filter_all_adults' => 'Tous les adultes', 'gift_role_proche_adulte' => 'Adulte (Oncle, ami...)',
'gift_empty_state_no_gifts' => 'Aucun cadeau pour le moment.', 'gift_role_proche_enfant' => 'Enfant (Neveu, nièce...)',
'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond au filtre.', 'gift_ph_proche_name' => 'Prénom',
'gift_view_matrix' => 'Voir la matrice détaillée', '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 // 9. GARAGE
// ========================================== // ==========================================
'menu_garage' => 'Garage',
'garage_page_title' => 'HouseHub - Garage', 'garage_page_title' => 'HouseHub - Garage',
'garage_stat_vehicles' => 'Véhicules', 'garage_stat_vehicles' => 'Véhicules',
'garage_stat_maintenances' => 'Entretiens', 'garage_stat_maintenances' => 'Entretiens',
@@ -582,7 +929,6 @@ return [
'garage_photo' => 'Photo', 'garage_photo' => 'Photo',
'garage_notes' => 'Notes', 'garage_notes' => 'Notes',
'garage_maint_type' => 'Type d\'entretien', 'garage_maint_type' => 'Type d\'entretien',
'garage_date' => 'Date',
'garage_km_at' => 'Kilométrage au moment', 'garage_km_at' => 'Kilométrage au moment',
'garage_labor_cost' => 'Coût main d\'œuvre (€)', 'garage_labor_cost' => 'Coût main d\'œuvre (€)',
'garage_description' => 'Description', 'garage_description' => 'Description',
@@ -601,4 +947,79 @@ return [
'garage_change_photo' => 'Changer la photo', 'garage_change_photo' => 'Changer la photo',
'garage_new_photo' => 'Nouvelle photo', 'garage_new_photo' => 'Nouvelle photo',
'garage_update_photo' => 'Mettre à jour', 'garage_update_photo' => 'Mettre à jour',
];
// ==========================================
// 10. LISTES & COURSES
// ==========================================
'liste_placeholder_add' => 'Ajouter un article…',
'liste_btn_add' => 'Ajouter',
'liste_btn_uncheck_all' => 'Tout décocher',
'liste_btn_delete_picked' => 'Supprimer les cochés',
'liste_btn_clear_all' => 'Vider la liste',
'liste_subtitle' => '{pending} à faire · {cart} cochés',
'liste_added' => 'Ajouté',
'liste_updated' => 'Modifié',
'liste_deleted' => 'Supprimé',
'liste_already_in' => 'Déjà dans la liste',
'liste_confirm_clear' => 'Vider toute la liste ?',
'liste_confirm_delete_list' => 'Supprimer cette liste et tous ses articles ?',
'liste_history_title' => 'Récemment utilisés — clic pour rajouter',
'liste_new_list' => 'Nouvelle liste',
'liste_list_name_placeholder'=> 'Nom de la liste…',
'liste_create' => 'Créer',
'liste_rename_list' => 'Renommer',
'liste_new_name' => 'Nouveau nom :',
'liste_list_deleted' => 'Liste supprimée',
'liste_list_created' => 'Liste créée',
'liste_list_renamed' => 'Liste renommée',
'liste_edit_placeholder' => 'Modifier l\'article :',
'liste_empty_state' => 'Créez votre première liste avec le bouton + ci-dessus.',
'liste_settings_title' => 'Listes — historique',
'liste_settings_intro' => 'Nombre maximum d\'articles mémorisés dans l\'historique (commun à toutes les listes).',
'liste_settings_history_max' => 'Taille de l\'historique',
'liste_settings_history_hint'=> 'Entre 1 et 50.',
'groceries_settings_saved' => 'Préférences enregistrées.',
'groceries_settings_error' => 'Impossible d\'enregistrer :',
'groceries_err_duplicate' => 'Ce produit est déjà dans la liste.',
// --- MODULE MEALS ---
'menu_meals' => 'Repas',
'mod_meals_name' => 'Menus',
'mod_meals_desc' => 'Planifiez les repas de la semaine pour toute la famille.',
'meals_board_title' => 'Planning Repas',
'meals_copy_lunch' => 'Copier le midi entier',
'meals_copy_dinner' => 'Copier le soir entier',
'meal_lunch' => 'Midi',
'meal_dinner' => 'Soir',
'btn_apply_all' => 'Tous',
'btn_copy_to' => 'Copier vers...',
'modal_copy_title' => 'Copier vers...',
'source_individual' => 'Plat individuel',
'source_row' => 'Repas complet',
'week_current' => 'Semaine actuelle',
'week_next' => 'Semaine prochaine',
'meals_no_members' => 'Veuillez configurer les membres de votre famille dans les paramètres.',
'day_full_mon' => 'Lundi', 'day_full_tue' => 'Mardi', 'day_full_wed' => 'Mercredi',
'day_full_thu' => 'Jeudi', 'day_full_fri' => 'Vendredi', 'day_full_sat' => 'Samedi', 'day_full_sun' => 'Dimanche',
'meal_title' => 'Planificateur de Repas',
'meal_edit_title' => 'Modifier le repas',
'meal_label_name' => 'Menu prévu',
'meal_ph_name' => 'Ex: Lasagnes maison',
'meal_week' => 'Semaine',
'meal_empty_slot' => 'À définir...',
'meal_current_week' => 'Semaine en cours',
'meal_saved' => 'Repas enregistré !',
'meal_btn_import_pdf' => 'Importer Menu PDF',
'meal_import_title' => 'Importer un menu de cantine',
'meal_import_child' => 'Pour quel enfant ?',
'meal_select_child' => 'Sélectionner l\'enfant',
'meal_pdf_file' => 'Fichier PDF (Convivio)',
'meal_drop_pdf' => 'Cliquez pour ajouter le PDF',
'meal_import_success' => 'Menus importés avec succès !',
'mod_food_name' => 'Cuisine & Courses',
'food_tab_meals' => 'Menu de la semaine',
'food_tab_list' => 'Liste de courses',
'menu_food' => 'Food',
'mod_food_desc' => 'Gérez vos menus de la semaine et vos listes de courses partagées.',
'cta_food' => 'Mijoter',
];
+20 -4
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';
$meta_db = 'househub_meta'; $envHost = getenv('DB_HOST');
$meta_user = getenv('DB_USER') ?: 'househub';
$meta_pass = getenv('DB_PASS') ?: 'changeme'; // Détection stricte : Docker injecte toujours 'househub-db' via le docker-compose
if ($envHost === 'househub-db') {
// Environnement Docker (Ton collègue ou la Prod)
$meta_host = 'househub-db';
$meta_user = getenv('DB_USER') ?: 'househub';
$meta_pass = getenv('DB_PASS') ?: 'changeme';
} else {
// Environnement Local XAMPP (Toi)
$meta_host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
$meta_user = 'root';
$meta_pass = '';
}
$meta_db = 'househub_meta';
try { 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()]));
} }
?>
+48 -2
View File
@@ -43,7 +43,7 @@ if ($_has_custom_bg): ?>
<section class="pf-section"> <section class="pf-section">
<h2 style="color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.6);"><?= tr('home_modules_title') ?></h2> <h2 style="color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.6);"><?= tr('home_modules_title') ?></h2>
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','garage']; ?> <?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','food','garage','calendar_ios']; ?>
<div class="pf-modules-grid"> <div class="pf-modules-grid">
<?php if (in_array('calendar', $mods)): ?> <?php if (in_array('calendar', $mods)): ?>
@@ -87,7 +87,53 @@ if ($_has_custom_bg): ?>
<div class="pf-card-icon">🚗</div> <div class="pf-card-icon">🚗</div>
<h3 class="pf-card-title"><?= tr('mod_garage_name') ?></h3> <h3 class="pf-card-title"><?= tr('mod_garage_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_garage_desc') ?></div> <div class="pf-card-desc"><?= tr('mod_garage_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_open') ?></span> <span class="pf-card-cta"><?= tr('cta_service') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('food', $mods)): ?>
<a href="/food.php" class="pf-module-card">
<div class="pf-card-icon">🍳</div>
<h3 class="pf-card-title"><?= tr('mod_food_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_food_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_food') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('memo', $mods)): ?>
<a href="/memo.php" class="pf-module-card">
<div class="pf-card-icon">📝</div>
<h3 class="pf-card-title"><?= tr('mod_memo_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_memo_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_jot') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('todo', $mods)): ?>
<a href="/todo.php" class="pf-module-card">
<div class="pf-card-icon">✅</div>
<h3 class="pf-card-title"><?= tr('mod_todo_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_todo_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_check') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?>
<a href="/calendar-ios.php" class="pf-module-card">
<div class="pf-card-icon">📱</div>
<h3 class="pf-card-title"><?= tr('mod_calendar_ios_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_calendar_ios_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_sync') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('printvault', $mods)): ?>
<a href="/printvault.php" class="pf-module-card">
<div class="pf-card-icon">🖨️</div>
<h3 class="pf-card-title"><?= tr('mod_printvault_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_printvault_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_print') ?></span>
</a> </a>
<?php endif; ?> <?php endif; ?>
+27 -1
View File
@@ -2,6 +2,7 @@
session_start(); session_start();
require_once __DIR__ . '/includes/i18n.php'; require_once __DIR__ . '/includes/i18n.php';
require_once __DIR__ . '/includes/meta_db.php'; require_once __DIR__ . '/includes/meta_db.php';
require_once __DIR__ . '/includes/auth.php';
$pageTitle = tr('login_title'); $pageTitle = tr('login_title');
$activePage = "login"; $activePage = "login";
@@ -14,6 +15,9 @@ if (isset($_SESSION['user'])) {
$error = null; $error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
$error = "Session invalide (CSRF). Rechargez la page.";
} else {
$username = trim($_POST['username'] ?? ''); $username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? ''; $password = $_POST['password'] ?? '';
@@ -36,6 +40,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} elseif ($user['family_id'] && !$user['family_active']) { } elseif ($user['family_id'] && !$user['family_active']) {
$error = "Espace familial désactivé. Contactez l'administrateur."; $error = "Espace familial désactivé. Contactez l'administrateur.";
} else { } else {
session_regenerate_id(true);
$_SESSION['user'] = [ $_SESSION['user'] = [
'id' => (int)$user['id'], 'id' => (int)$user['id'],
'username' => $user['username'], 'username' => $user['username'],
@@ -45,7 +50,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]; ];
$_SESSION['family_db'] = $user['db_name']; $_SESSION['family_db'] = $user['db_name'];
$_SESSION['app_lang'] = $user['lang'] ?? 'fr'; $_SESSION['app_lang'] = $user['lang'] ?? 'fr';
$_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts"]', true); $_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts","calendar_ios"]', true);
// --- NOUVEAU : Logique "Se souvenir de moi" ---
if (isset($_POST['remember_me']) && $_POST['remember_me'] === '1') {
$token = bin2hex(random_bytes(32)); // Token aléatoire sécurisé
$hashedToken = hash('sha256', $token); // On stocke le hash en BDD
$meta_pdo->prepare("UPDATE users SET remember_token = ? WHERE id = ?")->execute([$hashedToken, $user['id']]);
// Création du cookie sécurisé (Expire dans 30 jours, HTTPOnly)
$cookieValue = $user['id'] . ':' . $token;
setcookie('hh_remember', $cookieValue, time() + (86400 * 30), '/', '', isset($_SERVER['HTTPS']), true);
}
// ----------------------------------------------
$redirectTo = $_GET['redirect'] ?? '/index.php'; $redirectTo = $_GET['redirect'] ?? '/index.php';
header('Location: ' . $redirectTo); header('Location: ' . $redirectTo);
exit; exit;
@@ -54,6 +73,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$error = tr('error_invalid_credentials'); $error = tr('error_invalid_credentials');
} }
} }
}
} }
require __DIR__ . '/header.php'; require __DIR__ . '/header.php';
@@ -75,6 +95,7 @@ require __DIR__ . '/header.php';
<?php endif; ?> <?php endif; ?>
<form method="post" action="/login.php<?= isset($_GET['redirect']) ? '?redirect=' . urlencode($_GET['redirect']) : '' ?>"> <form method="post" action="/login.php<?= isset($_GET['redirect']) ? '?redirect=' . urlencode($_GET['redirect']) : '' ?>">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<div class="pf-form-group"> <div class="pf-form-group">
<label class="pf-label" for="username"><?= tr('label_username') ?></label> <label class="pf-label" for="username"><?= tr('label_username') ?></label>
<input type="text" id="username" name="username" class="pf-input" <input type="text" id="username" name="username" class="pf-input"
@@ -88,6 +109,11 @@ require __DIR__ . '/header.php';
required placeholder="••••••" autocomplete="current-password"> required placeholder="••••••" autocomplete="current-password">
</div> </div>
<div class="pf-form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="remember_me" name="remember_me" value="1" class="pf-checkbox-lg">
<label class="pf-label" for="remember_me" style="margin: 0; cursor: pointer;"><?= tr('login_remember_me') ?? 'Se souvenir de moi' ?></label>
</div>
<button type="submit" class="pf-btn pf-btn-block"> <button type="submit" class="pf-btn pf-btn-block">
<?= tr('btn_login_submit') ?> <?= tr('btn_login_submit') ?>
</button> </button>
+16 -2
View File
@@ -1,6 +1,20 @@
<?php <?php
session_start(); session_start();
// --- NOUVEAU : Révocation du "Se souvenir de moi" ---
if (isset($_SESSION['user']['id'])) {
require_once __DIR__ . '/includes/meta_db.php';
// On invalide le token en base de données
$stmt = $meta_pdo->prepare("UPDATE users SET remember_token = NULL WHERE id = ?");
$stmt->execute([$_SESSION['user']['id']]);
}
// On détruit le cookie côté navigateur
if (isset($_COOKIE['hh_remember'])) {
setcookie('hh_remember', '', time() - 3600, '/', '', isset($_SERVER['HTTPS']), true);
}
// ----------------------------------------------------
// On vide toutes les variables de session // On vide toutes les variables de session
$_SESSION = []; $_SESSION = [];
@@ -21,6 +35,6 @@ if (ini_get("session.use_cookies")) {
); );
} }
// On renvoie vers la page de login (ou index si tu préfères) // On renvoie vers la page de login
header('Location: /login.php'); header('Location: /login.php');
exit; exit;
+139
View File
@@ -0,0 +1,139 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = 'Notes — HouseHub';
$activePage = 'memo';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/memo/assets/memo.css">
<div id="memo-toasts" class="memo-toast-container"></div>
<div class="memo-layout">
<!-- ── SIDEBAR ────────────────────────────────────────────────────────────── -->
<aside class="memo-sidebar" id="memo-sidebar">
<div class="memo-sidebar-top">
<button class="btn btn-primary" onclick="openCreate()" style="width:100%;justify-content:center">+ Nouvelle note</button>
<div class="memo-search">
<span style="color:var(--text-muted);font-size:.9rem">🔍</span>
<input type="text" id="memo-search-input" placeholder="Rechercher…">
</div>
</div>
<div class="memo-tags-list" id="memo-tags-list"></div>
</aside>
<!-- ── MAIN ───────────────────────────────────────────────────────────────── -->
<div class="memo-main">
<!-- Page : liste ─────────────────────────────────────────────────────── -->
<div id="memo-page-list" class="memo-page active">
<div class="memo-list-header">
<div>
<div id="memo-list-title" style="font-size:1rem;font-weight:700">📝 Notes</div>
<div id="memo-list-subtitle" class="memo-list-subtitle"></div>
</div>
<button class="btn btn-primary btn-sm" onclick="openCreate()">+ Nouvelle note</button>
</div>
<div id="memo-notes-grid" class="notes-grid"></div>
<div id="memo-pagination" style="display:flex;justify-content:space-between;align-items:center;padding:0 1.5rem 1.5rem;gap:.5rem"></div>
</div>
<!-- Page : vue note ──────────────────────────────────────────────────── -->
<div id="memo-page-view" class="memo-page">
<div class="memo-view-header">
<div class="memo-view-title" id="view-title"></div>
<div class="memo-view-meta">
<div id="view-tags" style="display:flex;gap:.35rem;flex-wrap:wrap"></div>
<span id="view-date" style="margin-left:auto"></span>
<div class="memo-view-actions">
<button class="btn btn-secondary btn-sm" onclick="loadList(currentQ,currentTag)">← Retour</button>
<button class="btn btn-secondary btn-sm" onclick="openEdit(currentNoteId)">✏️ Modifier</button>
<button class="btn btn-danger btn-sm" onclick="deleteNote(currentNoteId)">🗑️</button>
</div>
</div>
</div>
<div class="memo-content-area">
<div class="md-body" id="view-md"></div>
</div>
<div class="memo-attachments" id="view-attachments" style="display:none"></div>
</div>
<!-- Page : édition ───────────────────────────────────────────────────── -->
<div id="memo-page-edit" class="memo-page">
<div class="memo-edit-area">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.1rem;flex-wrap:wrap;gap:.5rem">
<h2 id="edit-page-title" style="font-size:1rem;font-weight:700;margin:0">Nouvelle note</h2>
<div style="display:flex;gap:.5rem">
<button class="btn btn-secondary" onclick="currentNoteId?viewNote(currentNoteId):loadList()">Annuler</button>
<button class="btn btn-primary" onclick="saveNote()">💾 Enregistrer</button>
</div>
</div>
<div class="memo-edit-form">
<!-- Titre -->
<div class="form-group">
<label class="form-label">Titre *</label>
<input type="text" id="edit-title" class="form-control" placeholder="Titre de la note…" required>
</div>
<!-- Contenu + preview -->
<div class="form-group">
<label class="form-label">Contenu <span style="font-size:.72rem;color:var(--text-muted)">(Markdown supporté)</span></label>
<div class="edit-split">
<textarea id="edit-content" class="form-control edit-textarea" placeholder="Écrivez ici en Markdown…"></textarea>
<div class="md-preview-box md-body" id="md-live-preview" style="min-height:320px"></div>
</div>
</div>
<!-- Tags -->
<div class="form-group">
<label class="form-label">Tags</label>
<div class="tags-input-wrap" onclick="document.getElementById('edit-tags-input').focus()">
<div id="edit-tags-chips" style="display:contents"></div>
<input type="text" id="edit-tags-input" placeholder="Ajouter un tag…" autocomplete="off">
</div>
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.25rem">Appuyez sur Entrée ou virgule pour valider</div>
</div>
<!-- Fichiers -->
<div class="form-group">
<label class="form-label">Fichiers & Images</label>
<div id="edit-drop-zone" class="attach-drop">
📎 Glissez-déposez des fichiers ou cliquez pour choisir (images, PDF…)
<input type="file" id="edit-file-input" multiple accept="image/*,.pdf,.doc,.docx,.txt,.csv,.zip" style="display:none">
</div>
<div id="edit-attach-preview" class="attach-preview-list"></div>
<div id="edit-existing-attachments" style="margin-top:.75rem"></div>
</div>
<!-- URLs -->
<div class="form-group">
<label class="form-label">Liens / URLs</label>
<div id="edit-url-rows"></div>
</div>
</div>
</div>
</div>
</div><!-- memo-main -->
</div><!-- memo-layout -->
<!-- Lightbox -->
<div id="memo-lightbox" class="lightbox">
<img src="" alt="" onclick="event.stopPropagation()">
</div>
<!-- marked.js pour le rendu Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
if(window.marked){
marked.setOptions({breaks:true,gfm:true});
}
</script>
<script src="/modules/memo/assets/memo.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+81
View File
@@ -0,0 +1,81 @@
<?php
// migrate.php
require __DIR__ . '/includes/auth.php';
require __DIR__ . '/includes/meta_db.php';
require_login();
if (empty($_SESSION['user']['is_admin'])) {
die("Accès refusé. Droits administrateur requis.");
}
try {
$stmt = $meta_pdo->query("SELECT db_name FROM families");
$families = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($families as $fam) {
$dbName = $fam['db_name'];
echo "Migration de la base : $dbName ... <br>";
// --- DÉBUT DU BLOC MODIFIÉ ---
$envHost = getenv('DB_HOST');
if ($envHost === 'househub-db') {
$host = 'househub-db';
$user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme';
} else {
$host = '127.0.0.1';
$user = 'root';
$pass = '';
}
$familyPdo = new PDO(
"mysql:host=$host;dbname=$dbName;charset=utf8mb4",
$user,
$pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// --- FIN DU BLOC MODIFIÉ ---
$sql = "
CREATE TABLE IF NOT EXISTS pf_meals_plan (
id INT AUTO_INCREMENT PRIMARY KEY,
plan_date DATE NOT NULL,
service ENUM('lunch', 'dinner') NOT NULL,
person_id INT NOT NULL,
meal_name VARCHAR(500) DEFAULT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_meal_plan (plan_date, service, person_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
";
$familyPdo->exec($sql);
echo "✅ Table pf_meals_plan créée pour $dbName.<br>";
}
echo "<br>🎉 Migration terminée avec succès.";
// À ajouter dans migrate.php
echo "Migration : Ajout des colonnes dynamiques au Budget...\n";
$stmtFam = $meta_pdo->query("SELECT id, db_name FROM families");
$families = $stmtFam->fetchAll(PDO::FETCH_ASSOC);
foreach ($families as $fam) {
$dbName = $fam['db_name'];
try {
$famPdo = new PDO("mysql:host=$meta_host;dbname=$dbName;charset=utf8mb4", $meta_user, $meta_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
// Vérification si la colonne n'existe pas déjà
$checkCol = $famPdo->query("SHOW COLUMNS FROM pf_budget_items LIKE 'is_dynamic'");
if ($checkCol->rowCount() == 0) {
$famPdo->exec("ALTER TABLE pf_budget_items
ADD COLUMN is_dynamic TINYINT(1) DEFAULT 0 AFTER is_estimate,
ADD COLUMN dynamic_code VARCHAR(50) DEFAULT NULL AFTER is_dynamic");
echo "Famille {$fam['id']} ($dbName) : Colonnes is_dynamic et dynamic_code ajoutées.\n";
}
} catch (\PDOException $e) {
echo "Erreur sur la famille {$fam['id']} ($dbName) : " . $e->getMessage() . "\n";
}
}
} catch (Exception $e) {
die("❌ Erreur lors de la migration : " . $e->getMessage());
}
+626
View File
@@ -0,0 +1,626 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>HouseHub - Planning Repas</title>
<style>
/* --- VARIABLES --- */
:root {
--primary: #3b82f6;
--bg-page: #f8fafc;
--bg-panel: #ffffff;
--bg-subtle: #f1f5f9;
--text-main: #0f172a;
--text-muted: #64748b;
--border-light: #e2e8f0;
--border-strong: #cbd5e1;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-lg: 0 -4px 20px rgba(0, 0, 0, 0.15);
--alert-success-bg: #dcfce7;
}
body {
font-family:
"Inter",
-apple-system,
sans-serif;
background: var(--bg-page);
color: var(--text-main);
margin: 0;
padding: 10px;
overscroll-behavior-y: none;
padding-bottom: 70px; /* Espace pour la toolbar fixe */
}
/* --- EN-TÊTE --- */
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.header-bar h1 {
margin: 0;
font-size: 1.3rem;
color: var(--text-main);
display: flex;
align-items: center;
gap: 8px;
}
.pf-btn {
background: var(--bg-panel);
color: var(--text-main);
border: 1px solid var(--border-strong);
padding: 8px 12px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
box-shadow: var(--shadow-sm);
}
.pf-btn.primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* --- MATRICE ULTRA-COMPACTE --- */
.meals-wrapper {
background: var(--bg-panel);
border-radius: 8px;
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.meals-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
}
.meals-table th,
.meals-table td {
border: 1px solid var(--border-light);
padding: 0;
text-align: center;
vertical-align: middle;
}
.meals-table th {
background: var(--bg-subtle);
font-size: 0.6rem;
font-weight: 800;
color: var(--text-muted);
padding: 6px 2px;
text-transform: uppercase;
}
.col-sticky {
width: 40px;
background: var(--bg-subtle);
border-right: 2px solid var(--border-strong) !important;
padding: 4px 2px !important;
}
.day-label {
font-size: 0.65rem;
font-weight: 800;
color: var(--text-main);
text-transform: uppercase;
display: block;
margin-bottom: 2px;
}
.day-label.invisible {
visibility: hidden;
height: 0;
margin: 0;
}
.service-icon {
background: white;
border: 1px solid var(--border-strong);
border-radius: 6px;
font-size: 1rem;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: transform 0.1s;
}
.service-icon:active {
transform: scale(0.9);
}
.row-lunch td {
border-top: 2px solid var(--border-strong) !important;
}
.row-dinner td {
border-top: 1px dashed var(--border-light);
}
.meal-input-cell {
position: relative;
height: 100%;
background: var(--bg-panel);
}
.meal-input {
width: 100%;
height: 44px;
border: none;
background: transparent;
text-align: center;
font-size: 0.75rem;
padding: 4px;
color: var(--text-main);
font-family: inherit;
resize: none;
box-sizing: border-box;
outline: none;
}
.meal-input:focus {
background: #eff6ff;
}
/* --- BARRE CONTEXTUELLE (FIXE EN BAS) --- */
.context-toolbar {
position: fixed;
bottom: -80px;
left: 0;
right: 0;
height: 60px;
background: var(--bg-panel);
border-top: 1px solid var(--border-strong);
box-shadow: var(--shadow-lg);
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
transition: bottom 0.2s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 1000;
padding: 0 10px;
}
.context-toolbar.active {
bottom: 0;
}
.ctx-btn {
background: var(--bg-subtle);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 10px 16px;
font-size: 0.85rem;
font-weight: 700;
color: var(--text-main);
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.ctx-btn.primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* --- MODALE : COPIER VERS (DESIGN GRILLE) --- */
.pf-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
align-items: center;
justify-content: center;
z-index: 2000;
}
.pf-modal.open {
display: flex;
animation: fadeInBg 0.2s;
}
.pf-modal-content {
background: var(--bg-panel);
width: 95%;
max-width: 600px;
border-radius: 12px;
padding: 20px;
box-sizing: border-box;
box-shadow: var(--shadow-lg);
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.modal-title {
margin: 0;
font-size: 1.1rem;
color: var(--text-main);
font-weight: 800;
display: flex;
align-items: center;
gap: 8px;
}
.modal-close {
background: none;
border: none;
font-size: 1.8rem;
cursor: pointer;
color: var(--text-muted);
line-height: 1;
margin-top: -5px;
}
.modal-source {
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 600;
margin: 0 0 20px 0;
}
/* Grille des Jours */
.week-section-title {
background: var(--bg-subtle);
text-align: center;
padding: 6px;
font-weight: 700;
font-size: 0.85rem;
color: var(--text-muted);
border-radius: 4px;
margin: 15px 0 10px 0;
text-transform: uppercase;
border: 1px solid var(--border-light);
}
.days-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.day-card {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 8px;
padding: 8px 4px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
box-shadow: var(--shadow-sm);
}
.day-card-title {
font-size: 0.8rem;
font-weight: 700;
color: var(--text-main);
}
.day-card-actions {
display: flex;
gap: 6px;
justify-content: center;
width: 100%;
}
.day-action-btn {
background: var(--bg-page);
border: 1px solid var(--border-strong);
border-radius: 6px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
cursor: pointer;
transition: 0.1s;
}
.day-action-btn:active {
transform: scale(0.9);
background: #e2e8f0;
}
@media (max-width: 380px) {
.days-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@keyframes fadeInBg {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
</head>
<body>
<div class="header-bar">
<h1>
<span style="opacity: 0.5">🍽️</span>
<span data-i18n="meals_board_title">Planning Repas</span>
</h1>
<button class="pf-btn" title="Générer les courses">
🛒 <span data-i18n="btn_groceries">Courses</span>
</button>
</div>
<!-- BARRE CONTEXTUELLE FIXE (Évite le conflit avec le clavier mobile) -->
<div class="context-toolbar" id="contextToolbar">
<button
class="ctx-btn"
onmousedown="
event.preventDefault();
applyToAllInRow();
"
>
➡️ <span data-i18n="btn_apply_all">Tous</span>
</button>
<button
class="ctx-btn primary"
onmousedown="
event.preventDefault();
openCopySheetFromCell();
"
>
🔄 <span data-i18n="btn_copy_to">Copier vers...</span>
</button>
</div>
<div class="meals-wrapper">
<table class="meals-table" id="mealsTable">
<thead>
<tr>
<th class="col-sticky"></th>
<th>PAPA</th>
<th>MAMAN</th>
<th>E1</th>
<th>E2</th>
</tr>
</thead>
<tbody>
<!-- Injecté via JS -->
</tbody>
</table>
</div>
<!-- MODALE DE COPIE (GRILLE LUNDI-DIMANCHE) -->
<div id="copyModal" class="pf-modal">
<div class="pf-modal-content">
<div class="modal-header">
<h3 class="modal-title">
🔄 <span data-i18n="modal_copy_title">Copier vers...</span>
</h3>
<button class="modal-close" onclick="closeCopyModal()">
&times;
</button>
</div>
<div id="copySourceLabel" class="modal-source">
Source : Plat individuel (Lundi Midi)
</div>
<div class="week-section-title" data-i18n="week_current">
Semaine actuelle
</div>
<div class="days-grid" id="currentWeekGrid"></div>
<div class="week-section-title" data-i18n="week_next">
Semaine prochaine
</div>
<div class="days-grid" id="nextWeekGrid"></div>
</div>
</div>
<script>
// --- I18N MOCKUP ---
window.I18N = {
meals_board_title: "Planning Repas",
btn_groceries: "Courses",
btn_apply_all: "Tous",
btn_copy_to: "Copier vers...",
modal_copy_title: "Copier vers...",
source_individual: "Plat individuel",
source_row: "Repas complet",
week_current: "Semaine actuelle",
week_next: "Semaine prochaine",
};
document
.querySelectorAll("[data-i18n]")
.forEach(
(el) => (el.innerHTML = window.I18N[el.getAttribute("data-i18n")]),
);
const days = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
const fullDays = [
"Lundi",
"Mardi",
"Mercredi",
"Jeudi",
"Vendredi",
"Samedi",
"Dimanche",
];
const membersCount = 4;
const tbody = document.querySelector("#mealsTable tbody");
let copyState = {
type: null,
dayIdx: null,
service: null,
personIdx: null,
sourceElements: [],
};
// --- 1. GÉNÉRATION DE LA GRILLE PRINCIPALE ---
days.forEach((day, index) => {
let trLunch = document.createElement("tr");
trLunch.className = "row-lunch";
trLunch.innerHTML = `
<td class="col-sticky">
<span class="day-label">${day}</span>
<div class="service-icon" onclick="openCopySheetFromRow(${index}, 'lunch')" title="Copier la ligne">☀️</div>
</td>`;
for (let p = 0; p < membersCount; p++) {
trLunch.innerHTML += `<td class="meal-input-cell"><textarea class="meal-input js-meal-input" data-d="${index}" data-s="lunch" data-p="${p}" placeholder="-"></textarea></td>`;
}
tbody.appendChild(trLunch);
let trDinner = document.createElement("tr");
trDinner.className = "row-dinner";
trDinner.innerHTML = `
<td class="col-sticky">
<span class="day-label invisible">${day}</span>
<div class="service-icon" onclick="openCopySheetFromRow(${index}, 'dinner')" title="Copier la ligne">🌙</div>
</td>`;
for (let p = 0; p < membersCount; p++) {
trDinner.innerHTML += `<td class="meal-input-cell"><textarea class="meal-input js-meal-input" data-d="${index}" data-s="dinner" data-p="${p}" placeholder="-"></textarea></td>`;
}
tbody.appendChild(trDinner);
});
// --- 2. GÉNÉRATION DES GRILLES DE LA MODALE COPIE ---
function buildModalGrid(containerId, weekTarget) {
const container = document.getElementById(containerId);
let html = "";
fullDays.forEach((dayName, idx) => {
html += `
<div class="day-card">
<div class="day-card-title">${dayName}</div>
<div class="day-card-actions">
<button type="button" class="day-action-btn" onclick="executeCopy(${idx}, 'lunch', '${weekTarget}')">☀️</button>
<button type="button" class="day-action-btn" onclick="executeCopy(${idx}, 'dinner', '${weekTarget}')">🌙</button>
</div>
</div>`;
});
container.innerHTML = html;
}
buildModalGrid("currentWeekGrid", "current");
buildModalGrid("nextWeekGrid", "next");
// --- 3. GESTION DU FOCUS & DE LA TOOLBAR ---
document.addEventListener("focusin", (e) => {
if (e.target.classList.contains("js-meal-input")) {
copyState = {
type: "cell",
dayIdx: e.target.getAttribute("data-d"),
service: e.target.getAttribute("data-s"),
personIdx: e.target.getAttribute("data-p"),
sourceElements: [e.target],
};
document.getElementById("contextToolbar").classList.add("active");
}
});
document.addEventListener("focusout", (e) => {
setTimeout(() => {
if (!document.activeElement.classList.contains("js-meal-input")) {
document
.getElementById("contextToolbar")
.classList.remove("active");
}
}, 150);
});
function flashSuccess(input) {
input.style.transition = "background 0.3s";
input.style.background = "var(--alert-success-bg)";
setTimeout(() => (input.style.background = "transparent"), 400);
}
// --- 4. ACTIONS DE COPIE ---
function applyToAllInRow() {
if (
copyState.type !== "cell" ||
!copyState.sourceElements[0].value.trim()
)
return;
const val = copyState.sourceElements[0].value;
const inputs = document.querySelectorAll(
`.js-meal-input[data-d="${copyState.dayIdx}"][data-s="${copyState.service}"]`,
);
inputs.forEach((inp) => {
if (inp !== copyState.sourceElements[0]) {
inp.value = val;
flashSuccess(inp);
}
});
}
function openCopySheetFromCell() {
if (
!copyState.sourceElements[0] ||
!copyState.sourceElements[0].value.trim()
)
return;
document.getElementById("contextToolbar").classList.remove("active");
document.activeElement.blur(); // Masque le clavier mobile
const svcLabel = copyState.service === "lunch" ? "Midi" : "Soir";
document.getElementById("copySourceLabel").innerText =
`Source : ${window.I18N.source_individual} (${fullDays[copyState.dayIdx]} ${svcLabel})`;
document.getElementById("copyModal").classList.add("open");
document.body.classList.add("no-scroll");
}
function openCopySheetFromRow(dIdx, service) {
const inputs = document.querySelectorAll(
`.js-meal-input[data-d="${dIdx}"][data-s="${service}"]`,
);
const hasContent = Array.from(inputs).some(
(inp) => inp.value.trim() !== "",
);
if (!hasContent) return;
copyState = {
type: "row",
dayIdx: dIdx,
service: service,
sourceElements: Array.from(inputs),
};
const svcLabel = service === "lunch" ? "Midi" : "Soir";
document.getElementById("copySourceLabel").innerText =
`Source : ${window.I18N.source_row} (${fullDays[dIdx]} ${svcLabel})`;
document.getElementById("copyModal").classList.add("open");
document.body.classList.add("no-scroll");
}
function closeCopyModal() {
document.getElementById("copyModal").classList.remove("open");
document.body.classList.remove("no-scroll");
}
function executeCopy(targetDayIdx, targetService, targetWeek) {
if (targetWeek === "next") {
alert("Simulation : Copié avec succès vers la SEMAINE PROCHAINE !");
closeCopyModal();
return;
}
if (copyState.type === "cell") {
const val = copyState.sourceElements[0].value;
const targetInput = document.querySelector(
`.js-meal-input[data-d="${targetDayIdx}"][data-s="${targetService}"][data-p="${copyState.personIdx}"]`,
);
if (targetInput) {
targetInput.value = val;
flashSuccess(targetInput);
}
} else if (copyState.type === "row") {
const targetInputs = document.querySelectorAll(
`.js-meal-input[data-d="${targetDayIdx}"][data-s="${targetService}"]`,
);
copyState.sourceElements.forEach((inp, idx) => {
if (inp.value.trim() !== "") {
targetInputs[idx].value = inp.value;
flashSuccess(targetInputs[idx]);
}
});
}
closeCopyModal();
}
</script>
</body>
</html>
+1809 -172
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,260 @@
<?php
// modules/budget/includes/DynamicBudgetCalculator.php
class DynamicBudgetCalculator {
private \PDO $pdo;
private int $year;
private string $month;
private array $settings = [];
private array $foyerSettings = [];
private array $kids = [];
private string $helperName = 'Intervenant'; // Valeur par défaut
private array $calendarEvents = [];
public function __construct(\PDO $pdo, int|string $year, int|string $month) {
$this->pdo = $pdo;
$this->year = (int)$year;
$this->month = str_pad((string)$month, 2, '0', STR_PAD_LEFT);
$this->loadSettings();
$this->loadPeople();
$this->loadEvents();
}
private function loadSettings(): void {
$stmt = $this->pdo->query("SELECT setting_key, setting_value FROM pf_settings WHERE module = 'budget'");
$this->settings = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
$this->foyerSettings = $this->pdo->query("SELECT * FROM pf_foyer_settings LIMIT 1")->fetch(PDO::FETCH_ASSOC);
}
private function getSetting(string $key, float|int $default = 0): float {
return isset($this->settings[$key]) ? (float)$this->settings[$key] : (float)$default;
}
private function loadPeople(): void {
// 1. Chargement des enfants sans doublons
$stmt = $this->pdo->query("SELECT id, name, care_modes FROM pf_people WHERE role IN ('enfant', 'child') AND is_active = 1");
$rawKids = $stmt->fetchAll(PDO::FETCH_ASSOC);
$this->kids = [];
$seenNames = [];
foreach ($rawKids as $k) {
$nameKey = mb_strtolower(trim($k['name']));
if (!isset($seenNames[$nameKey])) {
$this->kids[] = $k;
$seenNames[$nameKey] = true;
}
}
// 2. Chargement dynamique de l'intervenant (ex: Carole)
$stmtHelper = $this->pdo->query("SELECT name FROM pf_people WHERE role IN ('helper', 'nounou') AND is_active = 1 LIMIT 1");
$helper = $stmtHelper->fetchColumn();
if ($helper) {
$this->helperName = mb_strtoupper($helper);
}
}
private function loadEvents(): void {
$startDate = "{$this->year}-{$this->month}-01";
$endDate = date('Y-m-t', strtotime($startDate));
$stmt = $this->pdo->prepare("SELECT event_date, event_type, person_id FROM pf_events WHERE event_date BETWEEN ? AND ?");
$stmt->execute([$startDate, $endDate]);
$events = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($events as $e) {
$this->calendarEvents[$e['event_date']][] = $e;
}
}
private function getOffDays(): array {
$cacheKey = "offdays_{$this->year}";
$stmt = $this->pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'system_cache' AND reference_id = ?");
$stmt->execute([$cacheKey]);
$cached = $stmt->fetchColumn();
if ($cached) return json_decode($cached, true);
$offDays = ['feries' => [], 'vacances' => []];
try {
$jsonFeries = @file_get_contents("https://calendrier.api.gouv.fr/jours-feries/metropole/{$this->year}.json");
if ($jsonFeries) $offDays['feries'] = array_keys(json_decode($jsonFeries, true));
} catch (\Throwable $e) {}
$zone = $this->foyerSettings['zone_scolaire'] ?? 'C';
try {
$url = "https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=annee_scolaire='{$this->year}-" . ($this->year + 1) . "' AND zones LIKE '%Zone {$zone}%'&limit=100";
$jsonVacances = @file_get_contents($url);
if ($jsonVacances) {
$data = json_decode($jsonVacances, true);
if (!empty($data['results'])) {
foreach ($data['results'] as $r) {
$start = new DateTime(substr($r['start_date'], 0, 10));
$end = new DateTime(substr($r['end_date'], 0, 10));
if ($start->format('N') == 5) $start->modify('+1 day');
while ($start < $end) {
$offDays['vacances'][] = $start->format('Y-m-d');
$start->modify('+1 day');
}
}
}
}
} catch (\Throwable $e) {}
$this->pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('system_cache', ?, ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")
->execute([$cacheKey, json_encode($offDays)]);
return $offDays;
}
public function getEstimate(string $dynamicCode): array {
if ($dynamicCode === 'SCHOOL_ESTIMATE') {
return $this->calculateSchoolEstimate();
}
return ['amount' => 0.0, 'details' => tr('error_occured')];
}
private function calculateSchoolEstimate(): array {
$nannyFixed = $this->getSetting('budget_nanny_fixed', 700);
$nannyDaily = $this->getSetting('budget_nanny_daily', 4);
$nannyAid = $this->getSetting('budget_nanny_aid', 120);
$cesuAvg = $this->getSetting('budget_nanny_cesu_avg', 170);
$schoolMeal = $this->getSetting('budget_school_meal', 5.62);
$schoolAftercare = $this->getSetting('budget_school_aftercare', 1.97);
$schoolFullday = $this->getSetting('budget_school_fullday', 21.89);
$offDays = $this->getOffDays();
$totalCost = 0;
$details = sprintf(tr('bud_audit_month') . "\n\n", $this->month, $this->year);
$hasNanny = false;
$nannyOffDays = 0;
$nannyGlobalCost = $nannyFixed - $nannyAid - $cesuAvg;
$kidsStats = [];
// 1. Initialisation stricte
foreach ($this->kids as $k) {
$modes = json_decode($k['care_modes'] ?? '[]', true) ?: [];
$isNanny = count(array_intersect(array_map('strtolower', $modes), ['nounou', 'kangur'])) > 0;
if ($isNanny) $hasNanny = true;
$kidsStats[$k['id']] = [
'name' => $k['name'], 'isNanny' => $isNanny,
'nannyDays' => 0, 'sick' => 0,
'schoolMeal' => 0, 'schoolAftercare' => 0, 'center' => 0
];
}
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, (int)$this->month, $this->year);
// 2. Itération SANS pointeurs de référence
for ($d = 1; $d <= $daysInMonth; $d++) {
$dateStr = sprintf("%04d-%02d-%02d", $this->year, $this->month, $d);
$dayOfWeek = date('N', strtotime($dateStr));
if ($dayOfWeek >= 6) continue;
if (in_array($dateStr, $offDays['feries'])) continue;
$isVacances = in_array($dateStr, $offDays['vacances']);
$events = $this->calendarEvents[$dateStr] ?? [];
$nannyOffToday = false;
foreach ($events as $e) {
if (in_array($e['event_type'], ['HELPER_OFF', 'HELPER_EXTRA'])) $nannyOffToday = true;
}
if ($nannyOffToday) $nannyOffDays++;
foreach ($this->kids as $kid) {
$kidId = $kid['id'];
$isSick = false;
$hasCenterEvent = false;
foreach ($events as $e) {
if ($e['event_type'] === 'CHILD_SICK' && $e['person_id'] == $kidId) $isSick = true;
if (strtoupper($e['event_type']) === 'CENTRE' && $e['person_id'] == $kidId) $hasCenterEvent = true;
}
if ($isSick) {
$kidsStats[$kidId]['sick']++;
continue;
}
if ($kidsStats[$kidId]['isNanny']) {
if (!$nannyOffToday) $kidsStats[$kidId]['nannyDays']++;
} else {
if ($isVacances) {
if ($hasCenterEvent) $kidsStats[$kidId]['center']++;
} else {
if ($dayOfWeek == 3) {
$kidsStats[$kidId]['center']++;
} else {
$kidsStats[$kidId]['schoolMeal']++;
if (in_array($dayOfWeek, [2, 4])) $kidsStats[$kidId]['schoolAftercare']++;
}
}
}
}
}
$totalNannyDays = 0;
foreach ($kidsStats as $stat) {
if ($stat['isNanny']) {
$totalNannyDays += $stat['nannyDays'];
}
}
$totalNannyFees = $totalNannyDays * $nannyDaily;
$nannyGlobalCost = $nannyFixed + $totalNannyFees - $nannyAid - $cesuAvg;
// 3. Rédaction du Bilan
if ($hasNanny) {
$totalCost += max(0, $nannyGlobalCost);
$details .= sprintf(tr('bud_audit_nanny_full') . "\n",
$this->helperName, $nannyFixed, $totalNannyDays, $nannyDaily, $totalNannyFees, $nannyAid, $cesuAvg, max(0, $nannyGlobalCost));
if ($nannyOffDays > 0) {
$details .= sprintf(tr('bud_audit_nanny_abs') . "\n", $this->helperName, $nannyOffDays);
}
$details .= "\n";
}
foreach ($kidsStats as $finalStat) {
if ($finalStat['isNanny']) {
// L'enfant est chez la nounou : on ne l'affiche QUE s'il a été malade
if ($finalStat['sick'] > 0) {
$details .= sprintf(tr('bud_audit_child') . "\n", mb_strtoupper($finalStat['name']));
$details .= sprintf(tr('bud_audit_sick') . "\n", $finalStat['sick']) . "\n\n";
}
} else {
// L'enfant va à l'école / centre
$details .= sprintf(tr('bud_audit_child') . "\n", mb_strtoupper($finalStat['name']));
$costM = $finalStat['schoolMeal'] * $schoolMeal;
$costA = $finalStat['schoolAftercare'] * $schoolAftercare;
$costC = $finalStat['center'] * $schoolFullday;
$kidTotal = $costM + $costA + $costC;
$totalCost += $kidTotal;
$details .= sprintf(tr('bud_audit_school_fee') . "\n",
$finalStat['schoolMeal'], $finalStat['schoolAftercare'], $finalStat['center'], $kidTotal);
if ($finalStat['sick'] > 0) {
$details .= sprintf(tr('bud_audit_sick') . "\n", $finalStat['sick']) . "\n";
}
$details .= "\n";
}
}
return [
'amount' => round($totalCost, 2),
'details' => trim($details)
];
}
}
@@ -0,0 +1,30 @@
<?php
header('Content-Type: application/json');
// On charge l'authentification et l'init PDO de l'espace familial courant automatiquement via tes includes globaux
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$title = trim($_POST['title'] ?? '');
$amount = floatval($_POST['amount'] ?? 0);
$expected_date = $_POST['expected_date'] ?? '';
if (empty($title) || $amount <= 0 || empty($expected_date)) {
echo json_encode(['success' => false, 'message' => tr('error_generic')]);
exit;
}
try {
// Grâce au multi-tenant par BDD, $pdo pointe déjà sur la base de la famille connectée
$stmt = $pdo->prepare("INSERT INTO pf_expected_expenses (title, amount, expected_date) VALUES (?, ?, ?)");
$stmt->execute([$title, $amount, $expected_date]);
echo json_encode(['success' => true, 'message' => tr('success_add_provision')]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]);
}
@@ -0,0 +1,19 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
$id = intval($_POST['id'] ?? 0);
if (!$id) {
echo json_encode(['success' => false, 'message' => 'Invalid ID']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM pf_expected_expenses WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
Whitespace-only changes.
@@ -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()]);
}
+64 -48
View File
@@ -6,43 +6,54 @@ 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;
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; $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;
// ========================================== // 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;
}
// ==========================================
if ($id) { // 3. Exécution de la requête
// UPDATE if ($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=?"); // UPDATE
$stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id, $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=?");
} else { $stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id, $id]);
// INSERT } else {
$stmt = $pdo->prepare("INSERT INTO pf_budget_items (name, amount, category, type, payment_day, is_estimate, reg_month, mapping_keywords, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); // INSERT
$stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id]); $stmt = $pdo->prepare("INSERT INTO pf_budget_items (name, amount, category, type, payment_day, is_estimate, reg_month, mapping_keywords, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id]);
}
// 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');
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());
} }
// Redirection
header('Location: /budget.php?tab=recap');
exit;
} }
// --- ACTION : COCHER/DÉCOCHER RAPIDE (VIA JS FETCH) --- // --- ACTION : COCHER/DÉCOCHER RAPIDE (VIA JS FETCH) ---
@@ -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é
@@ -122,16 +138,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$is_credit = isset($_POST['is_credit']) ? (int)$_POST['is_credit'] : 0; $is_credit = isset($_POST['is_credit']) ? (int)$_POST['is_credit'] : 0;
$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()]);
}
+223 -77
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]);
@@ -60,59 +84,85 @@ if ($action === 'update_salary_config') {
// 2. MISE A JOUR TABLEAU REPARTITION (AJAX) // 2. MISE A JOUR TABLEAU REPARTITION (AJAX)
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') {
$name = trim($_POST['name']); try {
$target = trim($_POST['target']); $name = trim($_POST['name']);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; $transfer_dest = trim($_POST['transfer_dest'] ?? '');
$target = floatval($_POST['target'] ?? 0);
if (!empty($name)) { $holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
$stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, holiday_id) VALUES (?, ?, ?)");
$stmt->execute([$name, $target, $holiday_id]); if (!empty($name)) {
} $stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, transfer_dest, holiday_id) VALUES (?, ?, ?, ?)");
$stmt->execute([$name, $target, $transfer_dest, $holiday_id]);
// --- NOUVEAU : Réponse AJAX --- }
if (isset($_POST['ajax'])) {
header('Content-Type: application/json'); if (isset($_POST['ajax'])) {
echo json_encode(['success' => true]); header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
}
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());
} }
header("Location: " . $_SERVER['HTTP_REFERER']);
exit;
} }
// 4. MODIFICATION D'UNE CATEGORIE // 4. MODIFICATION D'UNE CATEGORIE
if ($action === 'update_category') { if ($action === 'update_category') {
$id = (int)$_POST['cat_id']; try {
$name = trim($_POST['name']); $id = (int)$_POST['cat_id'];
$target = trim($_POST['target']); $name = trim($_POST['name']);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; $transfer_dest = trim($_POST['transfer_dest'] ?? '');
$target = floatval($_POST['target'] ?? 0);
$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]); exit;
}
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());
} }
header("Location: " . $_SERVER['HTTP_REFERER']);
exit;
} }
// 5. SUPPRESSION CATEGORIE // 5. SUPPRESSION CATEGORIE
@@ -136,43 +186,71 @@ 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.");
$stmtAcc = $pdo->query("SELECT a.name as acc_name, p.name as owner_name FROM pf_bank_accounts a LEFT JOIN pf_people p ON a.owner_person_id = p.id");
$bankAccountsMap = [];
// Fallback intelligent : on récupère tous les prénoms de la famille
$familyNames = $pdo->query("SELECT name FROM pf_people")->fetchAll(PDO::FETCH_COLUMN);
while ($row = $stmtAcc->fetch(PDO::FETCH_ASSOC)) {
$owner = $row['owner_name'];
if (!$owner) {
// Si pas de propriétaire explicite, on cherche un prénom dans le nom du compte (ex: "Livret A Alex")
foreach ($familyNames as $pName) {
if (stripos($row['acc_name'], $pName) !== false) {
$owner = $pName;
break;
}
}
}
$bankAccountsMap['vers ' . $row['acc_name']] = $owner;
}
$stmt = $pdo->prepare(" $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 (isset($bankAccountsMap[$dest])) {
elseif ($target === 'vers L.Pol') { $targetOwner = 'Pol'; } $targetOwner = $bankAccountsMap[$dest];
elseif ($target === 'vers L.Pep') { $targetOwner = 'Pep'; } if (!$targetOwner) continue; // Compte commun sans propriétaire
elseif ($target === 'vers commune') { continue; } } elseif ($dest === 'vers L.Perso') { $targetOwner = $dbPersonName; }
elseif ($dest === 'vers L.Pol') { $targetOwner = 'Pol'; }
elseif ($dest === 'vers L.Pep') { $targetOwner = 'Pep'; }
elseif ($dest === 'vers commune') { continue; }
if ($targetOwner) { if ($targetOwner) {
if (!isset($transfersToDo[$targetOwner])) { if (!isset($transfersToDo[$targetOwner])) {
$transfersToDo[$targetOwner] = ['total_add' => 0, 'cats' => []]; $transfersToDo[$targetOwner] = ['total_add' => 0, 'cats' => []];
} }
$transfersToDo[$targetOwner]['total_add'] += $amount; $transfersToDo[$targetOwner]['total_add'] += $amount;
if (!isset($transfersToDo[$targetOwner]['cats'][$catName])) { if (!isset($transfersToDo[$targetOwner]['cats'][$catName])) {
$transfersToDo[$targetOwner]['cats'][$catName] = ['amount' => 0, 'holiday_id' => $holidayId]; $transfersToDo[$targetOwner]['cats'][$catName] = ['amount' => 0, 'holiday_id' => $holidayId];
} }
@@ -181,7 +259,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 +277,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 (preg_match('/^(Eco\s|Eco P\d|Livret)/i', $catName)) { continue; }
$stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?"); $stmtCheckCat = $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 +319,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;
} }
+56 -32
View File
@@ -3,23 +3,31 @@ 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']; $forceInsert = !empty($_POST['force_insert']);
try { try {
if ($amount == 0 && $cat !== 'TOTAL_BANQUE') { // On supprime la ligne si elle est à 0, SAUF si on demande explicitement son initialisation (force_insert)
// Si on met à 0 une ligne (autre que le total), on supprime l'entrée pour garder la base propre if ($amount == 0 && $cat !== 'TOTAL_BANQUE' && !$forceInsert) {
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE month_date=? AND owner=? AND category=?"); $stmt = $pdo->prepare("DELETE FROM pf_savings WHERE month_date=? AND owner=? AND category=?");
$stmt->execute([$month, $owner, $cat]); $stmt->execute([$month, $owner, $cat]);
} else { } else {
// Sinon on insère ou on met à jour
$stmt = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE amount = VALUES(amount)"); $stmt = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE amount = VALUES(amount)");
$stmt->execute([$month, $owner, $cat, $amount]); $stmt->execute([$month, $owner, $cat, $amount]);
} }
@@ -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)
header("Location: /budget.php?tab=epargne&owner=" . urlencode($redirectTab)); if ($isAjax) {
echo json_encode(['success' => true]);
} else {
header("Location: /budget.php?tab=epargne&owner=" . urlencode($redirectTab));
}
exit; exit;
} catch (Exception $e) { } catch (Exception $e) {
$pdo->rollBack(); if ($pdo->inTransaction()) $pdo->rollBack();
die("Erreur : " . $e->getMessage());
if ($isAjax) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} else {
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;
} }
+231
View File
@@ -0,0 +1,231 @@
<?php
require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.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;
// --- NOUVEAU : Récupération des paramètres dynamiques ---
$stmtSettings = $pdo->query("SELECT setting_key, setting_value FROM pf_settings WHERE module = 'budget'");
$budgetSettings = $stmtSettings->fetchAll(PDO::FETCH_KEY_PAIR);
// 6. Liste des membres (pour assigner un compte bancaire)
$people = $pdo->query("SELECT id, name FROM pf_people WHERE is_active = 1 ORDER BY name ASC")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'success' => true,
'data' => [
'accounts' => $accounts,
'categories' => $categories,
'rules' => $rules,
'salaries' => $salaries,
'people' => $people,
'year' => $currentYear,
'currency' => $currencySetting,
'csv_mapping' => $csvMapping,
'budget_settings' => $budgetSettings
]
]);
exit;
}
// --- GESTION DES COMPTES BANCAIRES ---
if ($action === 'add_account') {
$name = trim($_POST['name'] ?? '');
$type = $_POST['type'] ?? 'checking';
$owner_id = !empty($_POST['owner_person_id']) ? (int)$_POST['owner_person_id'] : null;
if (empty($name)) throw new Exception("Le nom du compte est obligatoire.");
$stmt = $pdo->prepare("INSERT INTO pf_bank_accounts (name, account_type, is_default, owner_person_id) VALUES (?, ?, 0, ?)");
$stmt->execute([$name, $type, $owner_id]);
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);
$stmtCheck = $pdo->prepare("SELECT code FROM pf_budget_categories WHERE id = ?");
$stmtCheck->execute([$id]);
$catCode = $stmtCheck->fetchColumn();
if ($catCode && in_array(strtoupper($catCode), ['FIXED', 'INCOME'])) {
throw new Exception(tr('bud_err_delete_system_cat'));
}
$stmt = $pdo->prepare("DELETE FROM pf_budget_categories WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
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;
}
// --- GESTION DES ESTIMATIONS DYNAMIQUES ---
if ($action === 'save_dynamic_estimates') {
$keys = [
'budget_nanny_fixed', 'budget_nanny_daily', 'budget_nanny_aid', 'budget_nanny_cesu_avg',
'budget_school_meal', 'budget_school_aftercare', 'budget_school_fullday'
];
$pdo->beginTransaction();
$stmt = $pdo->prepare("INSERT INTO pf_settings (setting_key, setting_value, module) VALUES (?, ?, 'budget') ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()");
foreach ($keys as $k) {
if (isset($_POST[$k])) {
$stmt->execute([$k, $_POST[$k]]);
}
}
$pdo->commit();
echo json_encode(['success' => true]);
exit;
}
throw new Exception("Action API inconnue.");
} catch (\Throwable $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
?>
File diff suppressed because it is too large. Load diff
+260 -203
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, color FROM pf_people ORDER BY id ASC");
$familyParents = [];
$familyKids = [];
$personColors = [];
while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) {
$role = strtolower(trim($row['role'] ?? ''));
$personColors[$row['name']] = $row['color'] ?? '#3b82f6';
if ($role === 'parent') {
$familyParents[] = $row['name'];
} elseif ($role === 'nounou' || $role === 'helper') {
continue;
} else {
$familyKids[] = $row['name'];
}
}
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,21 @@ 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> <?php if (!empty($familyKids)): ?>
<a href="?tab=epargne&owner=Laia" class="owner-tab <?= $requestedOwner === 'Laia' ? 'active' : '' ?>">Laia</a> <a href="?tab=epargne&owner=KIDS" class="owner-tab <?= $requestedOwner === 'KIDS' ? 'active' : '' ?>">
<a href="?tab=epargne&owner=Nens" class="owner-tab <?= $requestedOwner === 'Nens' ? 'active' : '' ?>">Nens 👶</a> <?= tr('budget_tab_kids') ?>
</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,183 +80,174 @@ 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 $safeOwnerCls = htmlspecialchars(str_replace(' ', '_', $currentOwner), ENT_QUOTES);
$ownerTextClass = ''; $ownerColor = $personColors[$currentOwner] ?? '#1e293b';
if ($currentOwner === 'Alex') $ownerTextClass = 'txt-alex';
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 class="epargne-header-bar <?= ($requestedOwner === 'KIDS' && $index > 0) ? 'pf-mt-40' : '' ?>">
<div style="flex-grow: 1;"> <div class="pf-flex-1">
<?php if ($requestedOwner === 'Nens'): <?php if ($requestedOwner === 'KIDS'): ?>
$themeClass = 'theme-' . strtolower($currentOwner); <h3 class="nens-title" style="--owner-color: <?= htmlspecialchars($ownerColor) ?>;">
?> <?= htmlspecialchars($currentOwner) ?>
<h3 class="nens-title <?= $themeClass ?>" style="margin:0; font-size:1.2rem;">
<?= $currentOwner ?>
</h3> </h3>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div style="display: flex; gap: 10px; align-items: center;"> <div class="pf-flex-gap-12-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>
<button onclick="promptNewSavingsLine('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', '<?= $months[0] ?>')" class="pf-btn">
+ <?= tr('bud_sav_new_line') ?>
</button>
<?php else: ?>
<button onclick="openCustomSavingsModal('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn">
+ <?= tr('bud_sav_add_month') ?>
</button>
<?php endif; ?> <?php endif; ?>
<button onclick="openCustomSavingsModal('<?= $currentOwner ?>')" class="pf-btn">
+ <?= tr('bud_sav_add_month') ?>
</button>
</div> </div>
</div> </div>
<div class="table-responsive" style="background:white; border-radius:16px; box-shadow:var(--shadow-sm); border:1px solid #e2e8f0;"> <div class="table-responsive pf-card-epargne">
<?php if (empty($months)): ?> <?php if (empty($months)): ?>
<div style="padding: 30px; text-align: center; color: #64748b;"> <div class="pf-empty-dashed">
<p><?= sprintf(tr('bud_sav_no_data'), htmlspecialchars($currentOwner)) ?></p> <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;"> <div class="epargne-grid-table" style="--cols: <?= count($months) ?>; --owner-color: <?= htmlspecialchars($ownerColor) ?>;">
<thead>
<tr> <div class="eg-header-row">
<th class="sticky-col" style="background:#f8fafc;"><?= tr('bud_sav_post_month') ?></th> <div class="eg-cell eg-sticky"><?= tr('bud_sav_post_month') ?></div>
<?php foreach ($months as $month): ?> <?php foreach ($months as $month): ?>
<th> <div class="eg-cell">
<div class="month-header-container" style="display: flex; flex-direction: column; align-items: center; gap: 6px;"> <div class="month-header-container">
<div style="display:flex; flex-direction:column; text-align:center;"> <div class="month-header-title">
<span class="month-name" style="text-transform:capitalize;"><?= getMonthName($month) ?></span> <span class="month-name text-inherit"><?= getMonthName($month) ?></span>
<?php <?php
if (isset($cycleConfigs[$month]) && !empty($cycleConfigs[$month]['start_date'])) { if (isset($cycleConfigs[$month]) && !empty($cycleConfigs[$month]['start_date'])) {
$cStart = date('d/m', strtotime($cycleConfigs[$month]['start_date'])); $cStart = date('d/m', strtotime($cycleConfigs[$month]['start_date']));
echo "<span style='font-size:0.75rem; font-weight:normal; color:#64748b;'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</span>"; echo "<span class='month-cycle-start'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</span>";
} }
?> ?>
</div>
<div class="month-actions" style="justify-content: center; width: 100%;">
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_edit_modal') ?>"
data-json="<?= htmlspecialchars(json_encode($data[$month] ?? []), ENT_QUOTES, 'UTF-8') ?>"
onclick='editCustomSavingsMonth("<?= $month ?>", "<?= $currentOwner ?>", JSON.parse(this.getAttribute("data-json")))'>
✏️
</button>
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_delete_month') ?>"
onclick="deleteEntireMonth('<?= $month ?>', '<?= $currentOwner ?>')"
style="color: #ef4444; border-color: #fca5a5; background: #fef2f2;">
🗑️
</button>
</div>
</div> </div>
</th> <div class="month-actions">
<?php endforeach; ?> <button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_edit_modal') ?>"
</tr> data-json="<?= htmlspecialchars(json_encode($data[$month] ?? []), ENT_QUOTES, 'UTF-8') ?>"
</thead> onclick='editCustomSavingsMonth("<?= $month ?>", "<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>", JSON.parse(this.getAttribute("data-json")))'>
<tbody> ✏️
<tr class="row-total"> </button>
<td class="sticky-col"><strong><?= tr('bud_sav_total_bank') ?></strong></td> <button class="btn-icon-small btn-safe-click btn-danger-soft" title="<?= tr('bud_sav_delete_month') ?>"
<?php foreach ($months as $month): onclick="deleteEntireMonth('<?= $month ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')">
$val = $data[$month]['TOTAL_BANQUE'] ?? 0; 🗑️
?> </button>
<td class="text-center" style="padding:4px;">
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
<input type="number" step="0.01"
class="prev-input total-input-<?= $currentOwner ?>-<?= $month ?>"
style="width: 70px; font-weight:bold; color:#2563eb;"
value="<?= $val != 0 ? round($val) : '' ?>"
placeholder="0"
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= $currentOwner ?>', this)">
<span style="color:#2563eb; font-weight:bold; font-size:0.9rem;">€</span>
</div> </div>
</td> </div>
<?php endforeach; ?> </div>
</tr>
<?php foreach ($allCategories as $cat): ?>
<tr>
<td class="sticky-col"><?= htmlspecialchars($cat) ?></td>
<?php foreach ($months as $month):
$amount = $data[$month][$cat] ?? 0;
?>
<td class="text-center" style="padding:4px;">
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
<input type="number" step="0.01"
class="prev-input <?= $ownerTextClass ?> cat-input-<?= $currentOwner ?>-<?= $month ?>"
style="width: 70px;"
value="<?= $amount != 0 ? round($amount) : '' ?>"
placeholder="-"
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= $currentOwner ?>', this)">
</div>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?> <?php endforeach; ?>
</div>
<tr class="row-extres"> <div class="eg-row eg-total-row">
<td class="sticky-col"><strong><?= tr('bud_sav_extra') ?></strong></td> <div class="eg-cell eg-sticky"><strong><?= tr('bud_sav_total_bank') ?></strong></div>
<?php foreach ($months as $month): <?php foreach ($months as $month):
$total = $data[$month]['TOTAL_BANQUE'] ?? 0; $val = $data[$month]['TOTAL_BANQUE'] ?? 0;
$sum = 0; ?>
foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0); <div class="eg-cell">
$extra = $total - $sum; <div class="eg-cell-content">
?> <input type="number" step="0.01"
<td class="text-center font-bold sum-target" id="extra_<?= $currentOwner ?>_<?= $month ?>" style="color: <?= $extra >= 0 ? '#10b981' : '#ef4444' ?>; padding:12px;"> class="prev-input eg-input eg-input-total total-input-<?= $safeOwnerCls ?>-<?= $month ?>"
<?= number_format($extra, 0, ',', ' ') ?> € value="<?= $val != 0 ? round($val) : '' ?>"
</td> placeholder="0"
<?php endforeach; ?> onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
</tr> <span class="eg-currency-symbol">€</span>
</tbody> </div>
</table> </div>
<?php endforeach; ?>
</div>
<?php foreach ($allCategories as $cat): ?>
<div class="eg-row">
<div class="eg-cell eg-sticky"><?= htmlspecialchars($cat) ?></div>
<?php foreach ($months as $month):
$amount = $data[$month][$cat] ?? 0;
?>
<div class="eg-cell">
<input type="number" step="0.01"
class="prev-input eg-input cat-input-<?= $safeOwnerCls ?>-<?= $month ?>"
value="<?= $amount != 0 ? round($amount) : '' ?>"
placeholder="-"
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<div class="eg-row eg-extres-row">
<div class="eg-cell eg-sticky"><strong><?= tr('bud_sav_extra') ?></strong></div>
<?php foreach ($months as $month):
$total = $data[$month]['TOTAL_BANQUE'] ?? 0;
$sum = 0;
foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0);
$extra = $total - $sum;
?>
<div class="eg-cell font-bold sum-target <?= $extra >= 0 ? 'text-success' : 'text-danger' ?>" id="extra_<?= $safeOwnerCls ?>_<?= $month ?>">
<?= number_format($extra, 0, ',', ' ') ?> €
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<div id="savingsModal" class="pf-modal"> <div id="savingsModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 600px; width: 95%;"> <div class="pf-modal-content modal-epargne">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;"> <div class="pf-modal-header">
<h3 id="savingsModalTitle" class="pf-modal-title" style="margin:0;"><?= tr('bud_sav_modal_title_add') ?></h3> <h3 id="savingsModalTitle" class="pf-modal-title"><?= tr('bud_sav_modal_title_add') ?></h3>
<button type="button" onclick="document.getElementById('savingsModal').style.display='none'; document.body.classList.remove('no-scroll');" style="border:none; background:none; font-size:1.8rem; cursor:pointer; color:#64748b; line-height:1;">&times;</button> <button type="button" onclick="document.getElementById('savingsModal').classList.remove('open'); document.body.classList.remove('no-scroll');" class="pf-modal-close">&times;</button>
</div> </div>
<form action="modules/budget/includes/api/save-savings.php" method="POST" id="savingsForm"> <form action="/modules/budget/includes/api/save-savings.php" method="POST" id="savingsForm">
<input type="hidden" name="owner" id="sav_owner"> <input type="hidden" name="owner" id="sav_owner">
<input type="hidden" name="redirect_tab" id="redirect_tab" value="<?= htmlspecialchars($requestedOwner) ?>"> <input type="hidden" name="redirect_tab" id="redirect_tab" value="<?= htmlspecialchars($requestedOwner) ?>">
<input type="hidden" name="month_date" id="sav_date_hidden"> <input type="hidden" name="month_date" id="sav_date_hidden">
<div style="display:flex; gap:15px; margin-bottom:20px;"> <div class="pf-form-row">
<div class="form-group" style="flex:1; margin:0;"> <div class="pf-form-group pf-flex-1 pf-m-0">
<label class="pf-label"><?= tr('bud_sav_month_concerned') ?></label> <label class="pf-label"><?= tr('bud_sav_month_concerned') ?></label>
<input type="month" id="sav_month" required class="pf-input"> <input type="month" id="sav_month" required class="pf-input">
</div> </div>
<div class="form-group" style="flex:1; margin:0;"> <div class="pf-form-group pf-flex-1 pf-m-0">
<label class="pf-label"><?= tr('bud_sav_total_bank_eur') ?></label> <label class="pf-label"><?= tr('bud_sav_total_bank_eur') ?></label>
<input type="number" step="0.01" name="values[TOTAL_BANQUE]" id="sav_total" required class="pf-input no-spinners" style="font-weight:bold; color:#2563eb;"> <input type="number" step="0.01" name="values[TOTAL_BANQUE]" id="sav_total" required class="pf-input no-spinners pf-input-total">
</div> </div>
</div> </div>
<div class="separator" style="margin: 20px 0; border-bottom: 1px solid #e2e8f0;"></div> <hr class="pf-divider">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;"> <div class="ventilation-header">
<div> <div>
<h4 style="margin:0; font-size:1rem; color:#1e293b;"><?= tr('bud_sav_ventilation') ?></h4> <h4 class="pf-m-0 text-main"><?= tr('bud_sav_ventilation') ?></h4>
<span style="font-size:0.8rem; color:#64748b;"><?= tr('bud_sav_adj_help') ?></span> <span class="pf-muted-note"><?= tr('bud_sav_adj_help') ?></span>
</div> </div>
<button type="button" class="pf-btn btn-secondary" onclick="addCustomEpargneLine()" style="padding:4px 10px; height:auto; width:auto; font-size:0.9rem;">+ <?= tr('bud_sav_add_line') ?></button> <button type="button" class="pf-btn btn-secondary pf-btn-sm" onclick="addCustomEpargneLine()">+ <?= tr('bud_sav_add_line') ?></button>
</div> </div>
<div style="display:flex; gap:10px; padding:0 5px 5px 5px; font-size:0.8rem; color:#64748b; font-weight:600;"> <div class="ventilation-cols">
<div style="flex:2;"><?= tr('bud_category') ?></div> <div class="vl-col-name"><?= tr('bud_category') ?></div>
<div style="width:100px;"><?= tr('bud_sav_current') ?></div> <div class="vl-col-base"><?= tr('bud_sav_current') ?></div>
<div style="width:90px;"><?= tr('bud_sav_adjust') ?></div> <div class="vl-col-adj"><?= tr('bud_sav_adjust') ?></div>
<div style="width:100px;"><?= tr('bud_sav_new') ?></div> <div class="vl-col-final"><?= tr('bud_sav_new') ?></div>
<div style="width:28px;"></div> <div class="vl-col-btn"></div>
</div> </div>
<div id="linesContainer" style="max-height: 350px; overflow-y: auto; padding-right:5px; display:flex; flex-direction:column; gap:10px;"> <div id="linesContainer" class="ventilation-list"></div>
</div>
<div style="margin-top:25px; display:flex; justify-content:flex-end; gap:10px;"> <div class="modal-footer">
<button type="button" onclick="document.getElementById('savingsModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary" style="width:auto; margin:0;"><?= tr('btn_cancel') ?></button> <button type="button" onclick="document.getElementById('savingsModal').classList.remove('open'); document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn" style="width:auto; margin:0;"><?= tr('btn_save') ?></button> <button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</div> </div>
</form> </form>
</div> </div>
@@ -244,7 +264,6 @@ function getMonthName($dateString) {
</div> </div>
<script> <script>
// --- 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 || {}),
@@ -253,17 +272,48 @@ window.I18N = {
'bud_sav_ph_name': <?= json_encode(tr('bud_sav_ph_name')) ?>, 'bud_sav_ph_name': <?= json_encode(tr('bud_sav_ph_name')) ?>,
'bud_sav_confirm_delete_month': <?= json_encode(tr('bud_sav_confirm_delete_month')) ?>, 'bud_sav_confirm_delete_month': <?= json_encode(tr('bud_sav_confirm_delete_month')) ?>,
'bud_sav_prompt_duplicate': <?= json_encode(tr('bud_sav_prompt_duplicate')) ?>, 'bud_sav_prompt_duplicate': <?= json_encode(tr('bud_sav_prompt_duplicate')) ?>,
'bud_sav_prompt_new_line': <?= json_encode(tr('bud_sav_prompt_new_line')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>, 'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>, 'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>,
'bud_err_network_dup': <?= json_encode(tr('bud_err_network_dup')) ?>, 'bud_err_network_dup': <?= json_encode(tr('bud_err_network_dup')) ?>,
'bud_sav_saving': <?= json_encode(tr('bud_sav_saving')) ?>, 'bud_sav_saving': <?= json_encode(tr('bud_sav_saving')) ?>,
'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?> 'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?>,
'btn_delete': <?= json_encode(tr('btn_delete')) ?>
}; };
// --- 2. GESTION DE L'ÉDITION INVISIBLE EN DIRECT --- const systemCurrency = (typeof window.CONFIG !== 'undefined' && window.CONFIG.CURRENCY) ? window.CONFIG.CURRENCY : '€';
const cycleConfigs = <?= json_encode($cycleConfigs ?? []) ?>; const cycleConfigs = <?= json_encode($cycleConfigs ?? []) ?>;
function updateEpargneCell(month, category, owner, inputEl) { async function promptNewSavingsLine(owner, latestMonth) {
const catName = prompt(window.I18N['bud_sav_prompt_new_line']);
if (!catName || catName.trim() === "") return;
const formData = new FormData();
formData.append('action', 'update_single_entry');
formData.append('month_date', latestMonth);
formData.append('category', catName.trim());
formData.append('owner', owner);
formData.append('amount', 0);
formData.append('force_insert', '1'); // 🛡️ Empêche la suppression auto par l'API
formData.append('ajax', '1');
try {
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', {
method: 'POST',
body: formData
});
if (result.success) {
window.location.reload();
} else {
showToast(window.I18N['bud_err_tech'] + " : " + (result.error || "Erreur"), 'error');
}
} catch (err) {
showToast(window.I18N['bud_err_tech'], 'error');
}
}
async function updateEpargneCell(month, category, owner, inputEl) {
const val = parseFloat(inputEl.value) || 0; const 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,26 +321,40 @@ 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');
fetch('modules/budget/includes/api/save-savings.php', { try {
method: 'POST', const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: 'POST', body: formData });
body: formData if (!result.success) {
}).catch(err => alert(window.I18N['bud_err_tech'] || 'Erreur technique')); alert(window.I18N['bud_err_tech'] + " : " + (result.error || "Erreur serveur"));
return;
}
} catch (err) {
showToast(window.I18N['bud_err_tech'], 'error');
return;
}
const totalInput = document.querySelector(`.total-input-${owner}-${month}`); 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) + ' €';
extraCell.style.color = extra >= 0 ? '#10b981' : '#ef4444'; if (extra >= 0) {
extraCell.classList.remove('text-danger');
extraCell.classList.add('text-success');
} else {
extraCell.classList.remove('text-success');
extraCell.classList.add('text-danger');
}
} }
if(isSumModeActive) updateSumResult(); if(isSumModeActive) updateSumResult();
@@ -302,20 +366,20 @@ function addCustomEpargneLine(catName = '', amount = '') {
const inputName = catName ? `values[${catName}]` : ''; const inputName = catName ? `values[${catName}]` : '';
const html = ` const html = `
<div class="ventilation-line" style="display:flex; gap:10px; align-items:center; background:#f8fafc; padding:8px; border-radius:8px; border:1px solid #e2e8f0;"> <div class="ventilation-line">
<div style="flex:2;"> <div class="vl-col-name">
<input type="text" class="pf-input cat-name-input" value="${catName}" placeholder="${window.I18N['bud_sav_ph_name'] || 'Catégorie'}" oninput="updateCustomFieldName(this)" style="padding:6px; font-size:0.9rem;" required> <input type="text" class="pf-input cat-name-input pf-input-sm" value="${catName}" placeholder="${window.I18N['bud_sav_ph_name'] || 'Catégorie'}" oninput="updateCustomFieldName(this)" required>
</div> </div>
<div style="width:100px;"> <div class="vl-col-base">
<input type="number" step="0.01" class="pf-input base-amount no-spinners" value="${baseAmount}" oninput="recalculateCustomLine(this)" style="padding:6px; font-size:0.9rem; background:#fff;"> <input type="number" step="0.01" class="pf-input base-amount no-spinners pf-input-sm bg-white" value="${baseAmount}" oninput="recalculateCustomLine(this)">
</div> </div>
<div style="width:90px;"> <div class="vl-col-adj">
<input type="number" step="0.01" class="pf-input adjustment-amount no-spinners" placeholder="+ / -" oninput="recalculateCustomLine(this)" style="padding:6px; font-size:0.9rem; color:#f59e0b; font-weight:bold;"> <input type="number" step="0.01" class="pf-input adjustment-amount no-spinners pf-input-sm vl-input-adj" placeholder="+ / -" oninput="recalculateCustomLine(this)">
</div> </div>
<div style="width:100px;"> <div class="vl-col-final">
<input type="number" step="0.01" name="${inputName}" class="pf-input final-amount no-spinners" value="${baseAmount}" style="padding:6px; font-size:0.9rem; font-weight:bold; background:#e0f2fe; border-color:#bae6fd; color:#0369a1;" readonly> <input type="number" step="0.01" name="${inputName}" class="pf-input final-amount no-spinners pf-input-sm vl-input-final" value="${baseAmount}" readonly>
</div> </div>
<button type="button" onclick="this.parentElement.remove()" style="width:28px; height:28px; border:none; background:#fee2e2; color:#ef4444; border-radius:4px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-weight:bold;">&times;</button> <button type="button" class="btn-remove-line" onclick="this.parentElement.remove()" title="${window.I18N['btn_delete']}">&times;</button>
</div> </div>
`; `;
container.insertAdjacentHTML('beforeend', html); container.insertAdjacentHTML('beforeend', html);
@@ -361,7 +425,7 @@ function editCustomSavingsMonth(monthDate, owner, rowData) {
if (container.children.length === 0) addCustomEpargneLine(); if (container.children.length === 0) addCustomEpargneLine();
document.getElementById('savingsModal').style.display = 'flex'; document.getElementById('savingsModal').classList.add('open');
document.body.classList.add('no-scroll'); document.body.classList.add('no-scroll');
} }
@@ -376,21 +440,13 @@ function openCustomSavingsModal(owner) {
container.innerHTML = ''; container.innerHTML = '';
addCustomEpargneLine(); addCustomEpargneLine();
document.getElementById('savingsModal').style.display = 'flex'; document.getElementById('savingsModal').classList.add('open');
document.body.classList.add('no-scroll'); document.body.classList.add('no-scroll');
} }
window.onclick = function(event) {
const modal = document.getElementById('savingsModal');
if (event.target == modal) {
modal.style.display = 'none';
document.body.classList.remove('no-scroll');
}
}
const savingsForm = document.getElementById('savingsForm'); 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,24 +460,27 @@ if (savingsForm) {
submitBtn.disabled = true; submitBtn.disabled = true;
const formData = new FormData(this); const formData = new FormData(this);
formData.append('ajax', '1');
const actionUrl = this.getAttribute('action'); try {
const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl; const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: 'POST', body: formData });
if (result.success) {
fetch(finalUrl, { method: 'POST', body: formData }) window.location.reload();
.then(response => response.text()) } else {
.then(text => { window.location.reload(); }) alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (result.error || "Inconnue"));
.catch(error => { submitBtn.innerText = originalText;
console.error("Erreur:", error); submitBtn.disabled = false;
alert(window.I18N['bud_err_tech'] || 'Erreur Technique'); }
} catch (error) {
showToast(window.I18N['bud_err_tech'], 'error');
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'];
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 +488,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");
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) {
showToast(window.I18N['bud_err_tech'], 'error');
}
} }
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 +511,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'];
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 +528,18 @@ 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");
fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData }) try {
.then(async r => { const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: "POST", body: formData });
const text = await r.text(); if (result.success) {
try {
const d = JSON.parse(text);
if (d.success) window.location.reload();
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 => { alert(window.I18N['bud_err_network_dup']);
console.error(err); }
alert(window.I18N['bud_err_network_dup'] || 'Erreur réseau.');
});
} }
} }
@@ -528,7 +585,7 @@ function updateSumResult() {
total += val; total += val;
}); });
document.getElementById('sumResultValue').innerText = new Intl.NumberFormat(window.appLang, { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(total); 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) {
showToast(window.I18N['bud_err_tech'], 'error');
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
});
}
// --- 3. GESTION DE LA SUPPRESSION ---
async function deleteProvision(id) {
if (!confirm(window.I18N['btn_delete'] + ' ?')) return;
try {
const result = await pachaFetch('/modules/budget/includes/api/delete-provision.php', {
method: "POST",
body: new URLSearchParams({ id: id })
});
if (result.success) {
window.location.reload();
} else {
alert(result.message);
}
} catch(err) {
showToast(window.I18N['bud_err_tech'], 'error');
}
}
// --- 4. CONTROL DE LA MODALE D'OPTIMISATION ---
function openOptimizeModal() {
document.getElementById('optimization-results').innerHTML = `
<p class="optimization-hint">
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
</p>`;
document.getElementById('optimizeModal').classList.add('open');
document.body.classList.add('no-scroll');
}
function closeOptimizeModal() {
document.getElementById('optimizeModal').classList.remove('open');
document.body.classList.remove('no-scroll');
}
window.onclick = function(event) {
const modal = document.getElementById('optimizeModal');
if (event.target == modal) {
closeOptimizeModal();
}
}
// --- 5. EXECUTION ET SUBMIT DE L'ALGORITHME (formOptimize) ---
const formOptimize = document.getElementById('form-optimize');
if (formOptimize) {
formOptimize.addEventListener('submit', async function(e) {
e.preventDefault();
const resultsContainer = document.getElementById('optimization-results');
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.disabled = true;
resultsContainer.innerHTML = '<div class="optimization-hint">⏳ Analyse des quinzaines bancaires...</div>';
try {
const result = await pachaFetch(this.getAttribute('data-action'), {
method: 'POST',
body: new FormData(this)
});
if (result.success) {
resultsContainer.innerHTML = result.html;
} else {
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${result.message}</p>`;
}
} catch (error) {
console.error("Erreur d'optimisation :", error);
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${window.I18N['bud_err_tech']}</p>`;
} finally {
submitBtn.disabled = false;
}
});
}
</script>
+252 -269
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
FROM pf_expenses // On récupère tout en une seule requête pour éviter les décalages entre ID et Catégorie
WHERE gestion_month = ? AND budget_item_id IS NOT NULL $stmtReal = $pdo->prepare("
GROUP BY budget_item_id"; SELECT budget_item_id, category, SUM(amount) as total_real
$stmtReal = $pdo->prepare($sqlReal); FROM pf_expenses
WHERE gestion_month = ?
GROUP BY budget_item_id, category
");
$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,124 +78,169 @@ $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'];
$targetAbs = abs((float)$item['amount']); $totalRevenus += $mensualite;
$amountToAdd = ($item['type'] === 'Annuel') ? $targetAbs / 12 : $targetAbs;
if ($item['category'] === 'income') $totalRevenus += $amountToAdd; // Calcul du réel perçu
else $totalDepenses += $amountToAdd; $realPerçu = isset($realTotalsBySalary[$salary['id']]) ? (float)$realTotalsBySalary[$salary['id']] : 0;
$gap = $realPerçu - $mensualite;
// --- 2. CALCUL DU RÉEL (Logique optimisée) --- $isAutoChecked = ($realPerçu >= ($mensualite - 0.10));
$realSum = 0;
$hasMatchingExpense = false;
// A. Correspondance directe par ID
if (isset($realTotals[$item['id']])) {
$realSum = $realTotals[$item['id']];
$hasMatchingExpense = true;
}
// B. Correspondance par catégorie système (École, Essence, FMCG)
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'])) {
$keywords = array_map('trim', explode(',', $item['mapping_keywords']));
foreach ($unlinkedExpenses as $uexp) {
foreach ($keywords as $kw) {
if (!empty($kw) && stripos($uexp['label'], $kw) !== false) {
$realSum += (float)$uexp['amount'];
$hasMatchingExpense = true;
break;
}
}
}
}
}
$realAbs = abs($realSum);
$isAutoChecked = ($hasMatchingExpense && ($realAbs >= ($targetAbs - 0.10)));
$rowClass = ($item['category'] === 'income') ? 'row-income' : 'row-expense';
if ($item['is_estimate']) $rowClass .= ' row-estimate';
?> ?>
<tr class="<?= $rowClass ?>" style="border-bottom:1px solid #f1f5f9;"> <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']);
// --- 🚀 INTERCEPTION DYNAMIQUE ---
$calcDetails = "";
if (isset($item['is_dynamic']) && $item['is_dynamic'] == 1 && !empty($item['dynamic_code'])) {
require_once __DIR__ . '/../includes/DynamicBudgetCalculator.php';
// Facturation à terme échu : on recule d'un mois pour le calcul
$prevDate = date('Y-m-d', strtotime('-1 month', strtotime("$currentYear-$currentMonth-01")));
$calcYear = date('Y', strtotime($prevDate));
$calcMonth = date('m', strtotime($prevDate));
$calc = new DynamicBudgetCalculator($pdo, $calcYear, $calcMonth);
$estimateData = $calc->getEstimate($item['dynamic_code']);
$targetAbs = $estimateData['amount'];
$calcDetails = $estimateData['details'];
}
// --------------------------------
$amountToAdd = ($item['type'] === 'Annuel') ? $targetAbs / 12 : $targetAbs;
$totalDepenses += $amountToAdd;
// 2. RÉINITIALISATION STRICTE
$realSum = 0;
$hasMatchingExpense = false;
// A. Correspondance par ID direct (Le lien dur)
if (isset($realTotalsById[$item['id']])) {
$realSum = (float)$realTotalsById[$item['id']];
$hasMatchingExpense = true;
}
// B. Correspondance par mots-clés bancaires (Le lien intelligent pour les orphelines)
elseif (!empty($item['mapping_keywords'])) {
$keywords = array_map('trim', explode(',', $item['mapping_keywords']));
foreach ($unlinkedExpenses as $uexp) {
foreach ($keywords as $kw) {
if (!empty($kw) && stripos($uexp['label'], $kw) !== false) {
$realSum += (float)$uexp['amount'];
$hasMatchingExpense = true;
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);
$isAutoChecked = ($hasMatchingExpense && ($realAbs >= ($targetAbs - 0.10)));
$rowClass = 'row-expense' . ($item['is_estimate'] ? ' row-estimate' : '');
?>
<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 (!empty($calcDetails)): ?>
<span title="<?= htmlspecialchars($calcDetails) ?>" style="cursor:help; margin-left:4px; font-size:0.85rem;">ℹ️</span>
<?php endif; ?>
<?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 +248,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>Total Dépenses & 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 +280,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 +353,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'); e.preventDefault();
if (recapForm) { const btn = this.querySelector('button[type="submit"]');
recapForm.addEventListener('submit', async function(e) { btn.disabled = true; btn.innerText = '⏳ ...';
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerText;
submitBtn.innerText = window.I18N['bud_saving'] || '⏳ ...';
submitBtn.disabled = true;
const formData = new FormData(this); const formData = new FormData(this);
formData.append('ajax', '1');
const cats = formData.getAll('category_multi[]');
// 💡 Utilisation sécurisée de getAttribute formData.delete('category_multi[]');
const actionUrl = this.getAttribute('action'); formData.append('category', cats.join(','));
const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl;
formData.append('ajax', '1');
try { const url = this.getAttribute('action');
const response = await fetch(finalUrl, {
method: 'POST',
body: formData
});
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 { try {
const response = await fetch("modules/budget/includes/api/manage-item.php", { 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}`);
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>
+660
View File
@@ -0,0 +1,660 @@
<?php
/**
* @var bool $hasAccounts
* @var bool $hasCategories
* @var bool $hasSalaries
* @var bool $hasBudgetItems
* @var bool $isBudgetSetupOk
*/
// Sécurité : Ce fichier ne doit pas être appelé directement
if (!defined('CURRENCY')) {
define('CURRENCY', '€');
}
?>
<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>
<button class="bs-tab-btn" onclick="switchBsTab('children', this)">👶 <?= tr('bud_settings_dyn_title') ?></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>
<select name="owner_person_id" id="select-account-owner" class="pf-input bs-input-fixed">
<option value="">Commun</option>
</select>
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<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>
<!-- NOUVEAU PANE ENFANTS -->
<div id="pane-children" class="bs-pane">
<h4 class="bs-section-title">👶 <?= tr('bud_settings_dyn_title') ?></h4>
<p class="pf-muted-tiny bs-desc">Configurez ici les tarifs pour le calcul dynamique des frais de garde et de cantine.</p>
<form id="form-dynamic-estimates" onsubmit="saveDynamicEstimates(event)">
<h5 class="bs-section-subtitle bordered">Assistante Maternelle</h5>
<div class="bs-grid-2">
<div>
<label class="pf-label"><?= tr('bud_dyn_nanny_fixed') ?></label>
<input type="number" step="0.01" name="budget_nanny_fixed" id="budget_nanny_fixed" class="pf-input" value="700">
</div>
<div>
<label class="pf-label"><?= tr('bud_dyn_nanny_daily') ?></label>
<input type="number" step="0.01" name="budget_nanny_daily" id="budget_nanny_daily" class="pf-input" value="4">
</div>
<div>
<label class="pf-label"><?= tr('bud_dyn_nanny_aid') ?></label>
<input type="number" step="0.01" name="budget_nanny_aid" id="budget_nanny_aid" class="pf-input" value="120">
</div>
<div>
<label class="pf-label"><?= tr('bud_dyn_nanny_cesu_avg') ?></label>
<input type="number" step="0.01" name="budget_nanny_cesu_avg" id="budget_nanny_cesu_avg" class="pf-input" value="170">
</div>
</div>
<h5 class="bs-section-subtitle bordered" style="margin-top: 20px;">École & Centre de loisirs</h5>
<div class="bs-grid-2">
<div>
<label class="pf-label"><?= tr('bud_dyn_school_meal') ?></label>
<input type="number" step="0.01" name="budget_school_meal" id="budget_school_meal" class="pf-input" value="5.62">
</div>
<div>
<label class="pf-label"><?= tr('bud_dyn_school_aftercare') ?></label>
<input type="number" step="0.01" name="budget_school_aftercare" id="budget_school_aftercare" class="pf-input" value="1.97">
</div>
<div>
<label class="pf-label"><?= tr('bud_dyn_school_fullday') ?></label>
<input type="number" step="0.01" name="budget_school_fullday" id="budget_school_fullday" class="pf-input" value="21.89">
</div>
</div>
<div class="bs-text-right" style="margin-top: 20px;">
<button type="submit" class="btn btn-secondary">💾 <?= tr('btn_save') ?></button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tabsContainer = document.querySelector('.budget-tabs-container');
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'));
// Alimenter la liste des propriétaires pour les comptes
const ownerSelect = document.getElementById('select-account-owner');
if (ownerSelect && response.data.people) {
ownerSelect.innerHTML = '<option value="">Commun</option>';
response.data.people.forEach(p => {
ownerSelect.innerHTML += `<option value="${p.id}">${p.name}</option>`;
});
}
renderAccounts(response.data.accounts);
renderCategories(response.data.categories);
populateRuleCategories(response.data.categories);
renderRules(response.data.rules);
renderSalaries(response.data.salaries, response.data.year);
const currencySelect = document.getElementById('bs-currency-select');
if (currencySelect && response.data.currency) currencySelect.value = response.data.currency;
const bs = response.data.budget_settings || {};
if (document.getElementById('budget_nanny_fixed')) document.getElementById('budget_nanny_fixed').value = bs.budget_nanny_fixed || 700;
if (document.getElementById('budget_nanny_daily')) document.getElementById('budget_nanny_daily').value = bs.budget_nanny_daily || 4;
if (document.getElementById('budget_nanny_aid')) document.getElementById('budget_nanny_aid').value = bs.budget_nanny_aid || 120;
if (document.getElementById('budget_nanny_cesu_avg')) document.getElementById('budget_nanny_cesu_avg').value = bs.budget_nanny_cesu_avg || 170;
if (document.getElementById('budget_school_meal')) document.getElementById('budget_school_meal').value = bs.budget_school_meal || 5.62;
if (document.getElementById('budget_school_aftercare')) document.getElementById('budget_school_aftercare').value = bs.budget_school_aftercare || 1.97;
if (document.getElementById('budget_school_fullday')) document.getElementById('budget_school_fullday').value = bs.budget_school_fullday || 21.89;
} catch (err) {
console.error("Erreur chargement paramètres :", err);
const errorMsg = `<div class="pf-alert pf-alert--error bs-input-mb">❌ ${tr('error_loading_settings')} : ${err.message}</div>`;
['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="deleteBudgetCategory(${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) { if (typeof showToast === 'function') showToast(err.message, 'error'); else alert("Erreur : " + err.message); } }
const formAddAccount = document.getElementById('form-add-account');
if (formAddAccount) formAddAccount.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddAccount.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddAccount); fd.append('action', 'add_account'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddAccount.reset(); await loadBudgetSettingsData(); if (typeof showToast === 'function') showToast("Compte ajouté", 'success'); } catch (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); else alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
async function deleteBudgetCategory(id) {
if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete_cat'))) return;
try {
const fd = new FormData();
fd.append('action', 'delete_category');
fd.append('id', id);
const res = await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd });
if (!res.success) throw new Error(res.error || tr('error_occured'));
await loadBudgetSettingsData();
} catch (err) {
if (typeof showToast === 'function') {
showToast(err.message, 'error');
} else {
alert("Erreur : " + err.message);
}
}
}
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');
const res = await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd });
if (!res.success) throw new Error(res.error || "Erreur lors de l'ajout");
formAddCategory.reset();
await loadBudgetSettingsData();
if (typeof showToast === 'function') showToast("Catégorie ajoutée", "success");
} catch (err) {
if (typeof showToast === 'function') showToast(err.message, 'error'); else alert("Erreur : " + err.message);
} finally {
btn.innerText = oldText;
btn.disabled = false;
}
});
}
function populateRuleCategories(categories) { const select = document.getElementById('select-rule-category'); if (!select) return; select.innerHTML = ''; if (!categories) return; categories.forEach(cat => { const opt = document.createElement('option'); opt.value = cat.code; opt.textContent = `${cat.icon || ''} ${cat.label}`; select.appendChild(opt); }); }
async function deleteRule(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete_rule'))) return; try { const fd = new FormData(); fd.append('action', 'delete_rule'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); else alert("Erreur : " + err.message); } }
const formAddRule = document.getElementById('form-add-rule');
if (formAddRule) formAddRule.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddRule.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddRule); fd.append('action', 'add_rule'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddRule.reset(); await loadBudgetSettingsData(); if (typeof showToast === 'function') showToast("Règle ajoutée", 'success'); } catch (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); else alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
function inlineEditSalary(btnEl) {
const id = btnEl.dataset.id; const person = btnEl.dataset.person; const salary = btnEl.dataset.salary; const mensualite = btnEl.dataset.mensualite; const currency = '<?= CURRENCY ?>';
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(); if (typeof showToast === 'function') showToast("Salaire mis à jour", 'success'); } catch (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); else alert("Erreur : " + err.message); btn.innerText = '💾'; btn.disabled = false; } }
async function updateBudgetCurrency(selectEl) { try { const fd = new FormData(); fd.append('action', 'save_currency'); fd.append('currency', selectEl.value); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); if (typeof showToast === 'function') { showToast(`${tr('bs_currency_updated')} ✅`, 'success'); } await loadBudgetSettingsData(); } catch (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); else alert("Erreur : " + err.message); } }
// CSV
function toggleCsvAmountCols(type) { if (type === 'split') { document.getElementById('wrapper_col_credit').style.display = 'block'; document.getElementById('lbl_col_debit').innerText = tr('bs_csv_col_debit'); } else { document.getElementById('wrapper_col_credit').style.display = 'none'; document.getElementById('lbl_col_debit').innerText = tr('bs_csv_col_amount'); } }
function renderCsvMapping(mapping) { if (!mapping) return; document.getElementById('csv_delimiter').value = mapping.delimiter || ';'; document.getElementById('csv_date_format').value = mapping.date_format || 'd/m/Y'; document.getElementById('csv_col_date').value = mapping.col_date ?? 0; document.getElementById('csv_col_label').value = mapping.col_label ?? 1; document.getElementById('csv_col_ref').value = mapping.col_ref ?? 3; const amountType = mapping.amount_type || 'split'; document.getElementById('csv_amount_type').value = amountType; document.getElementById('csv_col_debit').value = mapping.col_debit ?? 8; document.getElementById('csv_col_credit').value = mapping.col_credit ?? 9; toggleCsvAmountCols(amountType); }
async function saveCsvMapping(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳...'; btn.disabled = true; try { const fd = new FormData(form); fd.append('action', 'save_csv_mapping'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); if (typeof showToast === 'function') showToast(tr('bs_csv_saved'), 'success'); else alert(tr('bs_csv_saved')); } catch (err) { if (typeof showToast === 'function') showToast(tr('error_occured') + " : " + err.message, 'error'); else alert(tr('error_occured') + " : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } }
const dropZone = document.getElementById('csv-drop-zone'); const fileInput = document.getElementById('csv_file_input');
if (dropZone && fileInput) {
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')) {
if (typeof showToast === 'function') showToast(tr('bs_csv_err_type'), 'error'); else alert(tr('bs_csv_err_type'));
return;
}
if (typeof showToast === 'function') { showToast(`${tr('bs_csv_file_ok')} : ${file.name}`, 'success'); }
const reader = new FileReader();
reader.onload = function(e) {
const text = e.target.result; const delimiter = document.getElementById('csv_delimiter').value || ';';
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');
}
async function saveDynamicEstimates(e) {
e.preventDefault();
const form = e.target;
const btn = form.querySelector('button');
const oldText = btn.innerText;
btn.innerText = '⏳...';
btn.disabled = true;
try {
const fd = new FormData(form);
fd.append('action', 'save_dynamic_estimates');
await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd });
if (typeof showToast === 'function') showToast(window.I18N['fc_settings_updated'] || 'Paramètres enregistrés', 'success');
} catch (err) {
if (typeof showToast === 'function') showToast((window.I18N['error_occured'] || 'Erreur') + " : " + err.message, 'error');
} finally {
btn.innerText = oldText;
btn.disabled = false;
}
}
</script>
+433 -263
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,133 @@ 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) {
$calcDetails = "";
// --- 🚀 INTERCEPTION DYNAMIQUE ---
if (isset($item['is_dynamic']) && $item['is_dynamic'] == 1 && !empty($item['dynamic_code'])) {
require_once __DIR__ . '/../includes/DynamicBudgetCalculator.php';
// Facturation à terme échu : on recule d'un mois
$prevDate = date('Y-m-d', strtotime('-1 month', strtotime("$viewY-$viewM-01")));
$calcYear = date('Y', strtotime($prevDate));
$calcMonth = date('m', strtotime($prevDate));
$calc = new DynamicBudgetCalculator($pdo, $calcYear, $calcMonth);
$estimateData = $calc->getEstimate($item['dynamic_code']);
$amt = $estimateData['amount'];
$calcDetails = $estimateData['details'];
}
// --------------------------------
$estimatesList[] = [
'name' => $name,
'amount' => $amt,
'categories' => !empty($catCode) ? array_map('trim', explode(',', $catCode)) : [],
'details' => $calcDetails
];
}
// 3. Attribution du budget aux jauges visuelles de la page
// Pour ne pas tout casser, on donne tout le plafond visuel à la 1ère catégorie de la liste
if (!empty($catCode)) {
$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]; foreach ($est['categories'] as $cCode) {
} if (!empty($cCode) && isset($totals[$cCode])) {
$spentForEstimate += $totals[$cCode];
if ($totals[$cCode] > 0 && isset($categoriesConfig[$cCode])) {
$detailsHover[] = strip_tags($categoriesConfig[$cCode]['label']) . " : " . number_format($totals[$cCode], 0) . "€";
}
}
}
$rem = max(0, $est['amount'] - $spentForEstimate);
if ($rem > 0) {
$reste_a_venir_calc += $rem;
$tooltip = !empty($detailsHover) ? implode(' | ', $detailsHover) : 'Aucune dépense pour le moment';
// --- INJECTION DU DÉTAIL DYNAMIQUE ---
if (!empty($est['details'])) {
$tooltip .= "\n\n📊 Détail du calcul :\n" . $est['details'];
}
$fmcg_depense = isset($totals['FMCG']) ? $totals['FMCG'] : 0; $pending_charges[] = [
$reste_fmcg = max(0, $budget_fmcg - $fmcg_depense); 'name' => 'Reste ' . $est['name'],
if ($reste_fmcg > 0) { 'amount' => $rem,
$reste_a_venir_calc += $reste_fmcg; 'tooltip' => $tooltip
$pending_charges[] = ['name' => tr('bud_rem_fmcg'), 'amount' => $reste_fmcg]; ];
} }
$essence_depense = isset($totals['Essence']) ? $totals['Essence'] : 0;
$reste_essence = max(0, $budget_essence - $essence_depense);
if ($reste_essence > 0) {
$reste_a_venir_calc += $reste_essence;
$pending_charges[] = ['name' => tr('bud_rem_fuel'), 'amount' => $reste_essence];
} }
// 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 +436,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'),
@@ -321,90 +446,90 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<div class="budget-view"> <div class="budget-view">
<div style="background:white; padding:20px; border-radius:16px; box-shadow:var(--shadow-sm); margin-bottom:24px; border:1px solid #e2e8f0; position:relative;"> <div class="bud-summary-card">
<?php if ($isClosed): ?> <?php if ($isClosed): ?>
<div style="position:absolute; top:0; left:0; right:0; background:#f1f5f9; padding:5px; text-align:center; font-size:0.8rem; font-weight:bold; color:#64748b; border-radius:16px 16px 0 0; border-bottom:1px solid #e2e8f0;"> <div class="bud-closed-banner">
🔒 <?= tr('bud_closed_archived') ?> 🔒 <?= tr('bud_closed_archived') ?>
</div> </div>
<div style="height:20px;"></div> <div style="height:20px;"></div>
<?php endif; ?> <?php endif; ?>
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px; margin-bottom: 20px;"> <div class="bud-header-group">
<div style="display:flex; align-items:center; gap:15px;"> <div class="bud-header-title-wrapper">
<h2 style="margin:0; font-size:1.3rem; color:#0f172a; text-transform:capitalize;"><?= tr('bud_budget_of') ?> <?= $monthName ?></h2> <h2 class="bud-header-title"><?= tr('bud_budget_of') ?> <?= $monthName ?></h2>
</div> </div>
<div class="action-toolbar" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;"> <div class="action-toolbar bud-toolbar">
<?php if (!$isClosed): ?>
<button type="button" class="pf-btn btn-secondary" onclick="openSuiviModal('importCsvModal')" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">
📂 <?= tr('bud_import_csv') ?? 'Importer CSV' ?>
</button>
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_close') ?>');">
<input type="hidden" name="action" value="close_month">
<input type="hidden" name="close_month_date" value="<?= $viewMonthDate ?>">
<input type="hidden" name="freeze_solde_actuel" value="<?= $solde_actuel ?>">
<input type="hidden" name="freeze_capacite_max" value="<?= $capacite_max ?>">
<input type="hidden" name="freeze_solde_theorique" value="<?= $solde_theorique ?>">
<input type="hidden" name="freeze_reste_a_venir" value="<?= $reste_a_venir ?>">
<button type="submit" class="pf-btn" style="background:linear-gradient(135deg, #10b981, #059669); padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔒 <?= tr('bud_close_btn') ?></button>
</form>
<?php else: ?>
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_reopen') ?>');">
<input type="hidden" name="action" value="reopen_month">
<button type="submit" class="pf-btn btn-secondary" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔓 <?= tr('bud_reopen_btn') ?></button>
</form>
<?php endif; ?>
<div class="suivi-nav-group">
<a href="<?= $prevLink ?>" class="suivi-btn-nav">◀</a>
<a href="<?= $defaultLink ?>" class="suivi-btn-nav"><?= tr('bud_active_month_btn') ?></a>
<a href="<?= $nextLink ?>" class="suivi-btn-nav">▶</a>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:15px; margin-bottom:25px;">
<div style="padding:15px; background:#f8fafc; border-radius:12px; position:relative; border:1px solid #e2e8f0;">
<div style="font-size:0.85rem; color:#64748b; margin-bottom:4px;"><?= tr('bud_bank_balance') ?> <?= $isClosed ? "(".tr('bud_frozen').")" : "(".tr('bud_current').")" ?></div>
<div style="font-size:1.4rem; font-weight:700; color:#0f172a;">
<?= number_format($solde_actuel, 2, ',', ' ') ?> €
</div>
<?php if (!$isClosed): ?> <?php if (!$isClosed): ?>
<button onclick="openSuiviModal('snapshotModal')" style="position:absolute; top:12px; right:12px; background:white; border:1px solid #cbd5e1; border-radius:6px; padding:4px 8px; cursor:pointer; font-size:0.8rem; color:#475569; transition:0.2s;" onmouseover="this.style.background='#f1f5f9'" onmouseout="this.style.background='white'">✏️ <?= tr('bud_update_btn') ?></button> <button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="openSuiviModal('importCsvModal')">
<?php endif; ?> 📂 <?= tr('bud_import_csv') ?? 'Importer CSV' ?>
</div> </button>
<div style="padding:15px; background:#f8fafc; border-radius:12px; border:1px solid #e2e8f0;"> <form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_close') ?>');">
<div style="font-size:0.85rem; color:#64748b; margin-bottom:4px;"><?= tr('bud_theoretical_balance') ?></div> <input type="hidden" name="action" value="close_month">
<div style="font-size:1.4rem; font-weight:700; color:<?= $solde_theorique < 0 ? '#ef4444' : '#334155' ?>;"> <input type="hidden" name="close_month_date" value="<?= $viewMonthDate ?>">
<?= number_format($solde_theorique, 2, ',', ' ') ?> € <input type="hidden" name="freeze_solde_actuel" value="<?= $solde_actuel ?>">
</div> <input type="hidden" name="freeze_capacite_max" value="<?= $capacite_max ?>">
</div> <input type="hidden" name="freeze_solde_theorique" value="<?= $solde_theorique ?>">
<input type="hidden" name="freeze_reste_a_venir" value="<?= $reste_a_venir ?>">
<div style="padding:15px; background:#fef2f2; border-radius:12px; border:1px solid #fecaca; position: relative;"> <button type="submit" class="pf-btn bud-btn-lock">🔒 <?= tr('bud_close_btn') ?></button>
<div style="font-size:0.85rem; color:#991b1b; margin-bottom:4px; display:flex; justify-content:space-between; align-items:center;"> </form>
<span><?= tr('bud_upcoming_charges') ?></span> <?php else: ?>
<button onclick="toggleDiv('pendingDetailsList')" style="background:none; border:none; cursor:pointer; font-size:1rem; padding:0; filter: grayscale(1); opacity: 0.7; transition: 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.7">👁️</button> <form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_reopen') ?>');">
</div> <input type="hidden" name="action" value="reopen_month">
<div style="font-size:1.4rem; font-weight:700; color:#b91c1c;"> <button type="submit" class="pf-btn btn-secondary pf-btn-small">🔓 <?= tr('bud_reopen_btn') ?></button>
- <?= number_format($reste_a_venir, 2, ',', ' ') ?> € </form>
</div> <?php endif; ?>
<div id="pendingDetailsList" style="display:none; margin-top: 12px; padding-top: 12px; border-top: 1px dashed #fca5a5; font-size: 0.8rem; color: #7f1d1d;">
<?php if (empty($pending_charges)): ?> <div class="suivi-nav-group">
<div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div> <a href="<?= $prevLink ?>" class="suivi-btn-nav">◀</a>
<?php else: ?> <a href="<?= $defaultLink ?>" class="suivi-btn-nav"><?= tr('bud_active_month_btn') ?></a>
<?php foreach($pending_charges as $pc): ?> <a href="<?= $nextLink ?>" class="suivi-btn-nav">▶</a>
<div style="display:flex; justify-content:space-between; margin-bottom:6px;">
<span><?= htmlspecialchars($pc['name']) ?></span>
<strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div> </div>
</div> </div>
</div>
<div class="bud-kpi-grid">
<div class="bud-kpi-card">
<div class="bud-kpi-label"><?= tr('bud_bank_balance') ?> <?= $isClosed ? "(".tr('bud_frozen').")" : "(".tr('bud_current').")" ?></div>
<div class="bud-kpi-value">
<?= number_format($solde_actuel, 2, ',', ' ') ?> €
</div>
<?php if (!$isClosed): ?>
<button onclick="openSuiviModal('snapshotModal')" class="bud-kpi-edit-btn">✏️ <?= tr('bud_update_btn') ?></button>
<?php endif; ?>
</div>
<div class="bud-kpi-card">
<div class="bud-kpi-label"><?= tr('bud_theoretical_balance') ?></div>
<div class="bud-kpi-value <?= $solde_theorique < 0 ? 'bud-kpi-value--danger' : '' ?>">
<?= number_format($solde_theorique, 2, ',', ' ') ?> €
</div>
</div>
<div class="bud-kpi-card bud-kpi-card--danger">
<div class="bud-kpi-label bud-kpi-label--danger">
<span><?= tr('bud_upcoming_charges') ?></span>
<button onclick="toggleDiv('pendingDetailsList')" class="bud-pending-toggle">👁️</button>
</div>
<div class="bud-kpi-value bud-kpi-value--danger">
- <?= number_format($reste_a_venir, 2, ',', ' ') ?> €
</div>
<div id="pendingDetailsList" class="bud-pending-list" style="display:none;">
<?php if (empty($pending_charges)): ?>
<div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div>
<?php else: ?>
<?php foreach($pending_charges as $pc): ?>
<div class="bud-pending-item" <?= !empty($pc['tooltip']) ? 'title="'.htmlspecialchars($pc['tooltip']).'" style="cursor:help;"' : '' ?>>
<span><?= htmlspecialchars($pc['name']) ?></span>
<strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<?php <?php
// --- 1. CALCULS DE L'ÉCHELLE DYNAMIQUE --- // --- 1. CALCULS DE L'ÉCHELLE DYNAMIQUE ---
@@ -430,92 +555,91 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
} }
?> ?>
<div style="margin-top: 65px; margin-bottom: 75px; position: relative; font-family: inherit;"> <div class="bud-scale-wrapper">
<div class="bud-scale-track">
<?php if ($posZero > 0): ?>
<div class="bud-scale-zero" style="width: <?= $posZero ?>%;"></div>
<?php endif; ?>
<div style="position: relative; height: 24px; background: #e2e8f0; border-radius: 12px; width: 100%; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);"> <div class="bud-scale-bar" style="left: <?= $leftActuel ?>%; width: <?= $widthActuel ?>%; border-radius: <?= $solde_actuel >= 0 ? '0 12px 12px 0' : '12px' ?>;"></div>
<?php if ($posZero > 0): ?>
<div style="position: absolute; left: 0; width: <?= $posZero ?>%; height: 100%; background: #fef2f2; border-radius: 12px 0 0 12px;"></div>
<?php endif; ?>
<div style="position: absolute; left: <?= $leftActuel ?>%; width: <?= $widthActuel ?>%; height: 100%; background: repeating-linear-gradient(45deg, #fbbf24, #fbbf24 10px, #f59e0b 10px, #f59e0b 20px); border-radius: <?= $solde_actuel >= 0 ? '0 12px 12px 0' : '12px' ?>; transition: width 0.5s ease-out;"></div>
<?php if ($solde_theorique < 0): ?>
<div style="position: absolute; left: <?= $leftDanger ?>%; width: <?= $widthDanger ?>%; height: 100%; background: repeating-linear-gradient(45deg, #ef4444, #ef4444 10px, #dc2626 10px, #dc2626 20px); border-radius: 12px 0 0 12px; opacity: 0.95;"></div>
<?php endif; ?>
</div>
<div style="position: absolute; left: <?= $posZero ?>%; top: -5px; height: 35px; width: 2px; background: #94a3b8; z-index: 10;"></div>
<div style="position: absolute; left: <?= $posZero ?>%; top: 32px; transform: translateX(-50%); font-size: 0.85rem; font-weight: 800; color: #64748b;">0 €</div>
<div style="position: absolute; left: <?= $posActuel ?>%; top: -15px; height: 40px; width: 3px; background: #1e293b; z-index: 15;"></div>
<div style="position: absolute; left: <?= $posActuel ?>%; top: -35px; transform: translateX(-50%); font-size: 0.8rem; font-weight: bold; color: #1e293b;"><?= tr('bud_bar_actual') ?? 'Actuel' ?></div>
<div style="position: absolute; left: <?= $posTheorique ?>%; top: 24px; height: 30px; width: 3px; background: <?= $solde_theorique >= 0 ? '#8b5cf6' : '#ef4444' ?>; z-index: 12;"></div>
<div style="position: absolute; left: <?= $posTheorique ?>%; top: 54px; transform: translateX(-50%); font-size: 0.85rem; font-weight: bold; color: <?= $solde_theorique >= 0 ? '#8b5cf6' : '#ef4444' ?>; white-space: nowrap;"><?= tr('bud_bar_theoretical') ?? 'Théorique' ?></div>
<?php
$overlapMax = abs($posMax - $posActuel) < 35;
$topMaxText = $overlapMax ? '-60px' : '-35px';
$heightMaxLine = $overlapMax ? '65px' : '40px';
$topMaxLine = $overlapMax ? '-40px' : '-15px';
?>
<div style="position: absolute; left: <?= $posMax ?>%; top: <?= $topMaxLine ?>; height: <?= $heightMaxLine ?>; width: 2px; background: #cbd5e1; z-index: 10;"></div>
<div style="position: absolute; left: <?= $posMax ?>%; top: <?= $topMaxText ?>; transform: translateX(calc(-100% + 10px)); font-size: 0.8rem; font-weight: bold; color: #64748b; white-space: nowrap; cursor: help;" title="<?= sprintf(tr('bud_capacity_tooltip'), number_format($solde_initial, 0), number_format($salaires_retenus, 0), number_format($rentrees_autres, 0)) ?>">
<?= tr('bud_bar_max_cap') ?? 'Capacité Max' ?> : <?= number_format($capacite_max, 0, ',', ' ') ?> € ℹ️
</div>
<?php if ($solde_theorique < 0): ?>
<div class="bud-scale-bar--danger" style="left: <?= $leftDanger ?>%; width: <?= $widthDanger ?>%;"></div>
<?php endif; ?>
</div>
<div class="bud-scale-marker bud-scale-marker--zero" style="left: <?= $posZero ?>%;"></div>
<div class="bud-scale-label bud-scale-label--zero" style="left: <?= $posZero ?>%;">0 €</div>
<div class="bud-scale-marker bud-scale-marker--actual" style="left: <?= $posActuel ?>%;"></div>
<div class="bud-scale-label bud-scale-label--actual" style="left: <?= $posActuel ?>%;"><?= tr('bud_bar_actual') ?? 'Actuel' ?></div>
<?php $colorTheo = $solde_theorique >= 0 ? 'var(--primary)' : 'var(--danger)'; ?>
<div class="bud-scale-marker bud-scale-marker--theo" style="left: <?= $posTheorique ?>%; background: <?= $colorTheo ?>;"></div>
<div class="bud-scale-label bud-scale-label--theo" style="left: <?= $posTheorique ?>%; color: <?= $colorTheo ?>;"><?= tr('bud_bar_theoretical') ?? 'Théorique' ?></div>
<?php
$overlapMax = abs($posMax - $posActuel) < 35;
$topMaxText = $overlapMax ? '-60px' : '-35px';
$heightMaxLine = $overlapMax ? '65px' : '40px';
$topMaxLine = $overlapMax ? '-40px' : '-15px';
?>
<div class="bud-scale-marker bud-scale-marker--max" style="left: <?= $posMax ?>%; top: <?= $topMaxLine ?>; height: <?= $heightMaxLine ?>;"></div>
<div class="bud-scale-label bud-scale-label--max" style="left: <?= $posMax ?>%; top: <?= $topMaxText ?>; transform: translateX(calc(-100% + 10px));" title="<?= sprintf(tr('bud_capacity_tooltip'), number_format($solde_initial, 0), number_format($salaires_retenus, 0), number_format($rentrees_autres, 0)) ?>">
<?= tr('bud_bar_max_cap') ?? 'Capacité Max' ?> : <?= number_format($capacite_max, 0, ',', ' ') ?> € ℹ️
</div> </div>
</div> </div>
</div>
<div class="categories-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:24px;"> <div class="bud-categories-grid">
<?php foreach ($categoriesConfig as $key => $conf): ?> <?php foreach ($categoriesConfig as $key => $conf): ?>
<div class="cat-card" <?= $isClosed ? 'style="opacity:0.8;"' : '' ?>> <div class="cat-card" <?= $isClosed ? 'style="opacity:0.8;"' : '' ?>>
<div class="cat-card-header" style="background:<?= $conf['color'] ?>15; border-bottom:1px solid <?= $conf['color'] ?>30;"> <div class="cat-card-header" style="background:<?= $conf['color'] ?>15; border-bottom:1px solid <?= $conf['color'] ?>30;">
<div class="cat-card-title-group"> <div class="cat-card-title-group">
<h3 style="margin:0; font-size:1rem; color:<?= $conf['color'] ?>;"><?= $conf['label'] ?></h3> <h3 style="margin:0; font-size:1rem; color:<?= $conf['color'] ?>;"><?= $conf['label'] ?></h3>
<div class="cat-card-subtitle"> <div class="cat-card-subtitle">
<?php $logic = getDisplayLogic($totals[$key], $conf['budget'], $conf['type']); echo $logic['text']; ?> <?php $logic = getDisplayLogic($totals[$key], $conf['budget'], $conf['type']); echo $logic['text']; ?>
</div>
</div> </div>
<?php if (!$isClosed): ?>
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes($conf['label']) ?>')">+</button>
<?php endif; ?>
</div>
<?php $barCol = ($key === 'Income') ? '#10b981' : ($logic['isOver'] ? '#ef4444' : $conf['color']); ?>
<div style="background:#f1f5f9; height:4px; width:100%;">
<div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div>
</div>
<div style="flex:1; max-height:300px; overflow-y:auto; padding:0;">
<?php if (empty($expensesByCategory[$key])): ?>
<div style="padding:20px; text-align:center; color:#cbd5e1; font-style:italic; font-size:0.85rem;"><?= tr('bud_no_lines') ?></div>
<?php else: ?>
<table style="width:100%; border-collapse:collapse; font-size:0.85rem;">
<?php foreach ($expensesByCategory[$key] as $exp): ?>
<tr style="border-bottom:1px solid #f8fafc;">
<td style="padding:10px 15px; color:#94a3b8;"><?= date('d/m', strtotime($exp['date_exp'])) ?></td>
<td style="padding:10px 5px; font-weight:500;">
<?= htmlspecialchars($exp['label']) ?>
</td>
<td style="padding:10px 15px; text-align:right; font-weight:600; color:<?= $exp['amount'] > 0 ? '#10b981' : '#1e293b' ?>;">
<?= $exp['amount'] > 0 ? '+' : '-' ?><?= number_format(abs($exp['amount']), 2) ?>
</td>
<?php if (!$isClosed): ?>
<td style="width:70px; padding-right:10px; text-align:right; white-space:nowrap;">
<button class="btn-icon-action edit" onclick='openEditModal(<?= json_encode($exp, JSON_HEX_APOS | JSON_HEX_QUOT) ?>)' title="<?= tr('edit') ?>">✏️</button>
<button class="btn-icon-action delete btn-safe-click" onclick="deleteExpense(<?= $exp['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div> </div>
<?php if (!$isClosed): ?>
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes(strip_tags($conf['label'])) ?>')">+</button>
<?php endif; ?>
</div> </div>
<?php endforeach; ?>
<?php $barCol = ($conf['db_type'] === 'Income') ? 'var(--success)' : ($logic['isOver'] ? 'var(--danger)' : $conf['color']); ?>
<div style="background:var(--bg-page); height:4px; width:100%;">
<div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div>
</div>
<div style="flex:1; max-height:300px; overflow-y:auto; padding:0;">
<?php if (empty($expensesByCategory[$key])): ?>
<div style="padding:20px; text-align:center; color:var(--text-muted); font-style:italic; font-size:0.85rem;"><?= tr('bud_no_lines') ?></div>
<?php else: ?>
<table class="bud-expense-table">
<?php foreach ($expensesByCategory[$key] as $exp): ?>
<tr class="bud-expense-row">
<td class="bud-expense-date"><?= date('d/m', strtotime($exp['date_exp'])) ?></td>
<td class="bud-expense-label">
<?= htmlspecialchars($exp['label']) ?>
</td>
<td class="bud-expense-amount" style="color:<?= $exp['amount'] > 0 ? 'var(--success)' : 'var(--text-main)' ?>;">
<?= $exp['amount'] > 0 ? '+' : '-' ?><?= number_format(abs($exp['amount']), 2) ?>
</td>
<?php if (!$isClosed): ?>
<td class="bud-expense-actions">
<button class="btn-icon-action edit" onclick='openEditModal(<?= json_encode($exp, JSON_HEX_APOS | JSON_HEX_QUOT) ?>)' title="<?= tr('edit') ?>">✏️</button>
<button class="btn-icon-action delete btn-safe-click" onclick="deleteExpense(<?= $exp['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div> </div>
</div> </div>
@@ -543,7 +667,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 +686,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 +808,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 +845,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 +856,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';
const list = document.getElementById('modalSuggestions'); list.innerHTML = ''; incSel.disabled = false;
if (suggestions[catKey]) { suggestions[catKey].forEach(i => { const op = document.createElement('option'); op.value = i; list.appendChild(op); }); } }
}
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 = '';
if (conf.suggestions) {
conf.suggestions.forEach(i => { const op = document.createElement('option'); op.value = i; list.appendChild(op); });
} }
} }
@@ -774,8 +907,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 +922,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 +959,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 +1027,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';
@@ -871,11 +1041,11 @@ document.addEventListener('DOMContentLoaded', () => {
formExpense.reset(); formExpense.reset();
window.location.reload(); window.location.reload();
} else { } else {
alert((window.I18N['error_occured'] || 'Erreur') + ' : ' + (result.error || 'Inconnue')); showToast((window.I18N['error_occured'] || 'Erreur') + ' : ' + (result.error || 'Inconnue'), 'error');
} }
} catch (error) { } catch (error) {
console.error("Erreur réseau :", error); console.error("Erreur réseau :", error);
alert(window.I18N['bud_err_tech'] || "Erreur critique réseau."); showToast(window.I18N['bud_err_tech'] || "Erreur critique réseau.", 'error');
} finally { } finally {
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.innerText = originalBtnText; submitBtn.innerText = originalBtnText;
+418
View File
@@ -0,0 +1,418 @@
<?php
ob_start();
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
require_once dirname(__DIR__, 2) . '/includes/db.php';
require_once dirname(__DIR__, 2) . '/includes/meta_db.php';
require_once __DIR__ . '/caldav_sync.php';
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$userId = (int)($_SESSION['user']['id'] ?? 0);
$familyId = (int)($_SESSION['user']['family_id'] ?? 0);
$meta_pdo->exec("
CREATE TABLE IF NOT EXISTS user_calendar_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
provider VARCHAR(50) NOT NULL DEFAULT 'icloud_caldav',
username VARCHAR(255) NOT NULL,
secret_encrypted TEXT NOT NULL,
dav_principal_url VARCHAR(1024) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL,
status VARCHAR(30) DEFAULT 'connected',
last_sync_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_provider (user_id, provider)
)");
try {
$meta_pdo->exec('ALTER TABLE user_calendar_integrations ADD COLUMN calendar_prefs_json TEXT NULL');
} catch (Throwable $e) {
// colonne déjà présente
}
$pdo->exec("
CREATE TABLE IF NOT EXISTS pf_calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY,
family_id INT NOT NULL,
created_by_user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT DEFAULT NULL,
location VARCHAR(255) DEFAULT NULL,
start_at DATETIME NOT NULL,
end_at DATETIME NOT NULL,
is_all_day TINYINT(1) DEFAULT 0,
timezone VARCHAR(64) DEFAULT 'Europe/Paris',
rrule VARCHAR(500) DEFAULT NULL,
status VARCHAR(50) DEFAULT 'confirmed',
external_uid VARCHAR(255) DEFAULT NULL,
sync_state VARCHAR(30) DEFAULT 'pending_push',
deleted_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_external_uid (external_uid)
)");
$pdo->exec("
CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
id INT AUTO_INCREMENT PRIMARY KEY,
calendar_event_id INT NOT NULL,
external_uid VARCHAR(255) NOT NULL,
external_etag VARCHAR(255) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL,
external_href VARCHAR(2048) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_calendar_event (calendar_event_id),
UNIQUE KEY uq_external_link (external_uid)
)");
try {
$pdo->exec('ALTER TABLE pf_calendar_event_links ADD COLUMN external_href VARCHAR(2048) DEFAULT NULL');
} catch (Throwable $e) {
// colonne déjà présente
}
function ios_ok($data): void { echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE); exit; }
function ios_err(string $message, int $status = 400): void { http_response_code($status); echo json_encode(['ok' => false, 'error' => $message]); exit; }
function ios_body(): array { return json_decode(file_get_contents('php://input'), true) ?? []; }
function ios_require_csrf(): void {
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
if (!verify_csrf($token)) {
ios_err('Token CSRF invalide', 403);
}
}
function ios_normalize_datetime(?string $s): ?string
{
if ($s === null || $s === '') {
return null;
}
$s = str_replace('T', ' ', trim($s));
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $s)) {
$s .= ':00';
}
return $s;
}
function ios_calendar_url_key(?string $url): string
{
if ($url === null || $url === '') {
return '';
}
return rtrim(trim($url), '/');
}
/** @return array<string, array{visible:bool, color:?string}> */
function ios_calendar_prefs_decode(?string $json): array
{
if ($json === null || $json === '') {
return [];
}
$d = json_decode($json, true);
if (!is_array($d)) {
return [];
}
$out = [];
foreach ($d as $k => $v) {
if (!is_string($k) || $k === '' || !is_array($v)) {
continue;
}
$key = ios_calendar_url_key($k);
if ($key === '') {
continue;
}
$out[$key] = [
'visible' => !array_key_exists('visible', $v) ? true : (bool) $v['visible'],
'color' => (isset($v['color']) && is_string($v['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $v['color'])) ? $v['color'] : null,
];
}
return $out;
}
/**
* @return list<array{url:string,url_key:string,display:string,visible:bool,color:?string}>
*/
function ios_list_calendar_sources_for_ui(array $integration, string $password, PDO $pdo): array
{
$prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
$urlsMeta = [];
$primary = $integration['calendar_url'] ?? '';
if (hh_caldav_url_is_icloud($primary)) {
try {
foreach (hh_icloud_discover_calendar_entries($integration['username'], $password) as $e) {
$k = ios_calendar_url_key($e['url']);
if ($k === '') {
continue;
}
$disp = trim((string) ($e['display'] ?? ''));
if ($disp === '') {
$disp = basename(parse_url($e['url'], PHP_URL_PATH) ?: '') ?: $k;
}
$urlsMeta[$k] = ['url' => rtrim($e['url'], '/') . '/', 'display' => $disp];
}
} catch (Throwable $e) {
}
} else {
$k = ios_calendar_url_key($primary);
if ($k !== '') {
$urlsMeta[$k] = ['url' => rtrim($primary, '/') . '/', 'display' => 'Calendrier'];
}
}
$stmt = $pdo->query("
SELECT DISTINCT l.calendar_url AS u
FROM pf_calendar_event_links l
INNER JOIN pf_calendar_events e ON e.id = l.calendar_event_id
WHERE e.deleted_at IS NULL AND l.calendar_url IS NOT NULL AND TRIM(l.calendar_url) != ''
");
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $u) {
$k = ios_calendar_url_key((string) $u);
if ($k === '' || isset($urlsMeta[$k])) {
continue;
}
$urlsMeta[$k] = [
'url' => rtrim((string) $u, '/') . '/',
'display' => basename(parse_url((string) $u, PHP_URL_PATH) ?: '') ?: $k,
];
}
$out = [];
foreach ($urlsMeta as $key => $meta) {
$p = $prefs[$key] ?? [];
$visible = !array_key_exists('visible', $p) ? true : (bool) $p['visible'];
$color = (isset($p['color']) && is_string($p['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $p['color'])) ? $p['color'] : null;
$out[] = [
'url' => $meta['url'],
'url_key' => $key,
'display' => $meta['display'],
'visible' => $visible,
'color' => $color,
];
}
usort($out, static fn ($a, $b) => strcasecmp($a['display'], $b['display']));
return $out;
}
$integrationStmt = $meta_pdo->prepare("SELECT * FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'");
$integrationStmt->execute([$userId]);
$integration = $integrationStmt->fetch();
if ($action === 'events' && $method === 'GET') {
$prefs = [];
if ($integration) {
$prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
}
$rows = $pdo->query("
SELECT e.*, l.calendar_url AS calendar_source_url
FROM pf_calendar_events e
LEFT JOIN pf_calendar_event_links l ON l.calendar_event_id = e.id
WHERE e.deleted_at IS NULL
ORDER BY e.start_at ASC
")->fetchAll();
$out = [];
foreach ($rows as $r) {
$key = ios_calendar_url_key($r['calendar_source_url'] ?? '');
if ($key !== '' && isset($prefs[$key]['visible']) && $prefs[$key]['visible'] === false) {
continue;
}
$r['display_color'] = null;
if ($key !== '' && !empty($prefs[$key]['color']) && is_string($prefs[$key]['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $prefs[$key]['color'])) {
$r['display_color'] = $prefs[$key]['color'];
}
$out[] = $r;
}
ios_ok($out);
}
if ($action === 'calendar_sources' && $method === 'GET') {
if (!$integration) {
ios_ok(['calendars' => []]);
}
try {
$ctx = ios_caldav_prepare($integration, $meta_pdo);
} catch (Throwable $e) {
ios_err('CalDAV: ' . $e->getMessage(), 400);
}
$integrationStmt->execute([$userId]);
$integrationFresh = $integrationStmt->fetch();
if (!$integrationFresh) {
ios_ok(['calendars' => []]);
}
ios_ok(['calendars' => ios_list_calendar_sources_for_ui($integrationFresh, $ctx['password'], $pdo)]);
}
if ($action === 'calendar_prefs' && $method === 'POST') {
ios_require_csrf();
if (!$integration) {
ios_err('Connexion iCloud non configurée.', 400);
}
$body = ios_body();
$raw = $body['prefs'] ?? null;
if (!is_array($raw)) {
ios_err('Format prefs invalide', 400);
}
$clean = [];
foreach ($raw as $key => $p) {
if (!is_array($p)) {
continue;
}
$urlKey = ios_calendar_url_key(is_string($key) ? $key : '');
if ($urlKey === '') {
continue;
}
$visible = array_key_exists('visible', $p) ? (bool) $p['visible'] : true;
$col = $p['color'] ?? null;
if ($col !== null && $col !== '' && is_string($col) && preg_match('/^#[0-9A-Fa-f]{6}$/', $col)) {
$col = '#' . strtolower(substr($col, 1));
} else {
$col = null;
}
$clean[$urlKey] = ['visible' => $visible, 'color' => $col];
}
$existing = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
foreach ($clean as $urlKey => $v) {
$existing[$urlKey] = $v;
}
$json = json_encode($existing, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$meta_pdo->prepare('UPDATE user_calendar_integrations SET calendar_prefs_json = ? WHERE id = ?')->execute([$json, $integration['id']]);
ios_ok(['saved' => true]);
}
if ($action === 'events' && $method === 'POST') {
ios_require_csrf();
$d = ios_body();
if (empty($d['title']) || empty($d['start_at']) || empty($d['end_at'])) {
ios_err('Champs requis manquants');
}
$stmt = $pdo->prepare("
INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, sync_state)
VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', 'pending_push')
");
$stmt->execute([
$familyId, $userId, trim($d['title']), $d['description'] ?? null, $d['location'] ?? null,
ios_normalize_datetime($d['start_at'] ?? null), ios_normalize_datetime($d['end_at'] ?? null),
]);
ios_ok(['id' => (int)$pdo->lastInsertId()]);
}
if ($action === 'events' && $method === 'PUT') {
ios_require_csrf();
$d = ios_body();
$id = (int)($d['id'] ?? 0);
if ($id <= 0) ios_err('ID invalide');
$stmt = $pdo->prepare("
UPDATE pf_calendar_events
SET title=?, description=?, location=?, start_at=?, end_at=?, updated_at=NOW(), sync_state='pending_push'
WHERE id=?
");
$stmt->execute([
trim($d['title'] ?? ''), $d['description'] ?? null, $d['location'] ?? null,
ios_normalize_datetime($d['start_at'] ?? null), ios_normalize_datetime($d['end_at'] ?? null), $id,
]);
ios_ok(['updated' => true]);
}
if ($action === 'events' && $method === 'DELETE') {
ios_require_csrf();
$d = ios_body();
$id = (int)($d['id'] ?? 0);
if ($id <= 0) ios_err('ID invalide');
$row = $pdo->prepare("SELECT id FROM pf_calendar_events WHERE id=?");
$row->execute([$id]);
if (!$row->fetch()) ios_err('Événement introuvable', 404);
$pdo->prepare("UPDATE pf_calendar_events SET deleted_at=NOW(), sync_state='pending_delete' WHERE id=?")->execute([$id]);
ios_ok(['deleted' => true]);
}
if ($action === 'sync_status' && $method === 'GET') {
if (!$integration) {
ios_ok(['message' => 'Aucune intégration iCloud configurée.']);
}
$msg = 'Connecté iCloud. Dernière synchro: ' . ($integration['last_sync_at'] ?? 'jamais');
ios_ok(['message' => $msg]);
}
if ($action === 'sync' && $method === 'POST') {
ios_require_csrf();
if (!$integration) ios_err('Connexion iCloud non configurée.', 400);
try {
$ctx = ios_caldav_prepare($integration, $meta_pdo);
$integration = $ctx['integration'];
$davPassword = $ctx['password'];
$syncUrls = ios_sync_calendar_collection_urls($integration, $davPassword);
$remoteEvents = ios_fetch_remote_events_all_calendars($integration, $davPassword);
$localStmt = $pdo->query("SELECT * FROM pf_calendar_events WHERE deleted_at IS NULL");
$localEvents = $localStmt->fetchAll();
$linkStmt = $pdo->prepare('SELECT external_href, calendar_url FROM pf_calendar_event_links WHERE calendar_event_id = ?');
foreach ($localEvents as $evt) {
if ($evt['sync_state'] === 'pending_push' || empty($evt['external_uid'])) {
$linkStmt->execute([$evt['id']]);
$lr = $linkStmt->fetch() ?: [];
$push = ios_push_event_to_remote(
$integration,
$evt,
$davPassword,
$lr['external_href'] ?? null,
$lr['calendar_url'] ?? null
);
if ($push['code'] >= 200 && $push['code'] < 300) {
$uid = $evt['external_uid'] ?: ('hh-' . $evt['id'] . '@househub');
$pdo->prepare("UPDATE pf_calendar_events SET external_uid=?, sync_state='synced', updated_at=NOW() WHERE id=?")->execute([$uid, $evt['id']]);
$pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag, external_href) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE external_etag=VALUES(external_etag), calendar_url=VALUES(calendar_url), external_href=COALESCE(VALUES(external_href), external_href), updated_at=NOW()")
->execute([$evt['id'], $uid, $integration['calendar_url'], null, $lr['external_href'] ?? null]);
}
}
}
$pendingDelete = $pdo->query("SELECT id, external_uid FROM pf_calendar_events WHERE deleted_at IS NOT NULL AND sync_state='pending_delete'")->fetchAll();
foreach ($pendingDelete as $evt) {
if (!empty($evt['external_uid'])) {
$linkStmt->execute([$evt['id']]);
$lr = $linkStmt->fetch() ?: [];
ios_delete_remote_event(
$integration,
$evt['external_uid'],
$davPassword,
$lr['external_href'] ?? null,
$lr['calendar_url'] ?? null
);
}
$pdo->prepare("DELETE FROM pf_calendar_event_links WHERE calendar_event_id=?")->execute([$evt['id']]);
$pdo->prepare("DELETE FROM pf_calendar_events WHERE id=?")->execute([$evt['id']]);
}
foreach ($remoteEvents as $remote) {
$href = $remote['_resource_url'] ?? null;
$colUrl = $remote['_calendar_collection_url'] ?? $integration['calendar_url'];
$existing = $pdo->prepare("SELECT id, updated_at FROM pf_calendar_events WHERE external_uid=? LIMIT 1");
$existing->execute([$remote['external_uid']]);
$row = $existing->fetch();
if (!$row) {
$pdo->prepare("
INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, external_uid, sync_state)
VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', ?, 'synced')
")->execute([$familyId, $userId, $remote['title'], $remote['description'], $remote['location'], $remote['start_at'], $remote['end_at'], $remote['external_uid']]);
$newId = (int)$pdo->lastInsertId();
$pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag, external_href) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE calendar_url=VALUES(calendar_url), external_href=COALESCE(VALUES(external_href), external_href), updated_at=NOW()")
->execute([$newId, $remote['external_uid'], $colUrl, null, $href]);
} elseif ($href) {
$pdo->prepare('UPDATE pf_calendar_event_links SET external_href = COALESCE(external_href, ?), calendar_url = COALESCE(calendar_url, ?) WHERE calendar_event_id = ?')
->execute([$href, $colUrl, $row['id']]);
}
}
$meta_pdo->prepare("UPDATE user_calendar_integrations SET last_sync_at=NOW(), status='connected' WHERE id=?")->execute([$integration['id']]);
$n = count($remoteEvents);
$nc = count($syncUrls);
ios_ok(['message' => 'Synchronisation terminée. ' . $n . ' événement(s) depuis ' . $nc . ' calendrier(s) distant(s).']);
} catch (Throwable $e) {
ios_err('CalDAV: ' . $e->getMessage(), 400);
}
}
ios_err('Action inconnue', 404);
@@ -0,0 +1,600 @@
/* ─── Page layout (cartes claires hors agenda) ─────────────────────────── */
.ios-calendar-wrap {
display: flex;
flex-direction: column;
gap: 16px;
}
.ios-calendar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ios-calendar-head h1 {
margin: 0;
font-size: 1.4rem;
}
.ios-agenda-intro {
margin: 0;
font-size: 0.9rem;
color: var(--pf-text-muted, #64748b);
line-height: 1.45;
}
.ios-cal-prefs-details {
margin-top: 14px;
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 10px;
padding: 10px 14px;
background: var(--pf-bg-page, #fff);
}
.ios-cal-prefs-summary {
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
}
.ios-cal-prefs-help {
margin: 10px 0 12px;
font-size: 0.85rem;
color: var(--pf-text-muted, #64748b);
line-height: 1.45;
}
.ios-cal-prefs-rows {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.ios-cal-pref-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 10px 12px;
padding: 8px 10px;
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 8px;
background: var(--pf-bg-lighter, #f8fafc);
}
.ios-cal-pref-row input[type="checkbox"] {
width: 18px;
height: 18px;
}
.ios-cal-pref-label {
font-size: 0.88rem;
min-width: 0;
word-break: break-word;
}
.ios-cal-pref-color {
width: 44px;
height: 36px;
padding: 2px;
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 8px;
cursor: pointer;
background: var(--pf-bg-page, #fff);
}
.ios-cal-pref-reset {
border: none;
background: transparent;
color: var(--pf-text-muted, #64748b);
font-size: 0.78rem;
text-decoration: underline;
cursor: pointer;
padding: 4px;
font-family: inherit;
white-space: nowrap;
}
.ios-cal-pref-reset:hover {
color: var(--pf-text-main, #0f172a);
}
.ios-cal-prefs-msg {
min-height: 1.25em;
font-size: 0.85rem;
margin: 0 0 10px;
}
@media (max-width: 640px) {
.ios-cal-pref-row {
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
}
.ios-cal-pref-color {
grid-column: 2;
}
.ios-cal-pref-reset {
grid-column: 2;
justify-self: start;
}
}
.ios-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.ios-form-full {
grid-column: 1 / -1;
}
.ios-form-actions {
display: flex;
gap: 8px;
}
.ios-events-list {
display: grid;
gap: 10px;
}
.ios-event-card {
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 10px;
padding: 12px;
background: var(--pf-bg-page, #fff);
}
.ios-event-card-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.ios-event-card-title {
font-weight: 700;
}
.ios-event-card-meta {
color: var(--pf-text-muted, #64748b);
font-size: 0.9rem;
}
.ios-event-card-actions {
display: flex;
gap: 8px;
}
/* ─── Agenda sombre (style type app calendrier) ─────────────────────────── */
.ios-agenda-card {
--ios-bg: #0a0a0c;
--ios-elev: #12121a;
--ios-border: rgba(255, 255, 255, 0.08);
--ios-text: #f1f5f9;
--ios-muted: #94a3b8;
--ios-now: #ef4444;
--ios-seg-bg: #1a1a22;
background: var(--ios-bg);
border: 1px solid var(--ios-border);
color: var(--ios-text);
border-radius: 12px;
overflow: hidden;
}
.ios-agenda-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--ios-border);
background: var(--ios-elev);
}
.ios-agenda-title {
font-size: 1.05rem;
font-weight: 700;
text-transform: capitalize;
min-width: 140px;
}
.ios-agenda-segments {
display: inline-flex;
background: var(--ios-seg-bg);
border-radius: 10px;
padding: 3px;
gap: 2px;
}
.ios-seg {
border: none;
background: transparent;
color: var(--ios-muted);
padding: 7px 16px;
border-radius: 8px;
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.ios-seg:hover {
color: var(--ios-text);
}
.ios-seg--active {
background: #2d2d38;
color: var(--ios-text);
font-weight: 600;
}
.ios-agenda-nav {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.ios-agenda-btn {
border: 1px solid var(--ios-border);
background: var(--ios-elev);
color: var(--ios-text);
border-radius: 8px;
padding: 6px 12px;
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.ios-agenda-btn:hover {
background: #1e1e28;
}
.ios-agenda-btn--ghost {
background: transparent;
}
.ios-agenda-today {
border: none;
background: transparent;
color: var(--ios-now);
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
padding: 6px 10px;
font-family: inherit;
}
.ios-agenda-add {
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--ios-border);
background: var(--ios-elev);
color: var(--ios-text);
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
}
.ios-agenda-add:hover {
background: #1e1e28;
}
.ios-agenda-listlink {
border: none;
background: transparent;
color: var(--ios-muted);
font-size: 0.82rem;
text-decoration: underline;
cursor: pointer;
padding: 6px 8px;
font-family: inherit;
}
.ios-agenda-listlink:hover {
color: var(--ios-text);
}
.ios-agenda-mount {
min-height: 200px;
}
.ios-agenda-list-hint {
margin: 0;
padding: 20px 16px;
color: var(--ios-muted);
font-size: 0.9rem;
line-height: 1.5;
}
/* Grille semaine / jour */
.ios-week-wrap {
display: flex;
flex-direction: column;
}
.ios-week-head {
display: grid;
grid-template-columns: 52px repeat(7, minmax(0, 1fr));
border-bottom: 1px solid var(--ios-border);
background: var(--ios-elev);
font-size: 0.78rem;
color: var(--ios-muted);
}
.ios-week-head--day {
grid-template-columns: 52px minmax(0, 1fr);
}
.ios-week-corner {
border-right: 1px solid var(--ios-border);
}
.ios-week-head-day {
text-align: center;
padding: 10px 4px;
border-left: 1px solid var(--ios-border);
}
.ios-week-head-num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
border-radius: 50%;
font-weight: 700;
color: var(--ios-text);
margin-right: 4px;
}
.ios-week-head-day--today .ios-week-head-num {
background: var(--ios-now);
color: #fff;
}
.ios-week-scroll {
max-height: min(70vh, 720px);
overflow-y: auto;
overflow-x: auto;
}
.ios-week-track {
display: flex;
min-width: min(100%, 720px);
align-items: stretch;
}
.ios-agenda-gutter {
width: 52px;
flex-shrink: 0;
border-right: 1px solid var(--ios-border);
background: var(--ios-bg);
}
.ios-agenda-gutter-cell {
height: 44px;
font-size: 0.72rem;
color: var(--ios-muted);
text-align: right;
padding-right: 8px;
line-height: 44px;
box-sizing: border-box;
}
.ios-agenda-cols-wrap {
flex: 1;
position: relative;
min-width: 0;
display: flex;
min-height: 704px;
}
.ios-agenda-cols {
flex: 1;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
min-width: 0;
min-height: 704px;
}
.ios-agenda-cols--day {
grid-template-columns: minmax(0, 1fr);
}
.ios-agenda-col {
border-left: 1px solid var(--ios-border);
background-color: var(--ios-bg);
background-image: repeating-linear-gradient(
to bottom,
transparent,
transparent 43px,
var(--ios-border) 43px,
var(--ios-border) 44px
);
}
.ios-agenda-col:first-child {
border-left: none;
}
.ios-agenda-col-inner {
position: relative;
}
.ios-event-block {
position: absolute;
border: none;
border-radius: 6px;
padding: 4px 6px;
text-align: left;
cursor: pointer;
overflow: hidden;
box-sizing: border-box;
color: #fff;
font-size: 0.72rem;
line-height: 1.25;
box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35);
}
.ios-event-block--custom {
border: none !important;
color: #fff !important;
box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35) !important;
}
.ios-event-block-title {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ios-cal-c0 {
background: linear-gradient(135deg, #166534, #15803d);
}
.ios-cal-c1 {
background: linear-gradient(135deg, #1e40af, #2563eb);
}
.ios-cal-c2 {
background: linear-gradient(135deg, #6b21a8, #7c3aed);
}
.ios-cal-c3 {
background: linear-gradient(135deg, #a16207, #ca8a04);
}
.ios-cal-c4 {
background: linear-gradient(135deg, #0f766e, #14b8a6);
}
.ios-cal-c5 {
background: linear-gradient(135deg, #9f1239, #e11d48);
}
.ios-now-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 6;
}
.ios-now-line-span {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--ios-now);
box-shadow: 0 0 10px rgba(239, 68, 68, 0.6);
}
.ios-now-lbl-span {
position: absolute;
left: 6px;
font-size: 0.68rem;
font-weight: 700;
color: var(--ios-now);
text-shadow: 0 0 6px #000;
}
/* Mois (dans carte sombre) */
.ios-month-agenda {
padding: 12px 14px 16px;
}
.ios-month-agenda-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 8px;
font-size: 0.72rem;
color: var(--ios-muted);
text-align: center;
}
.ios-month-agenda-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.ios-month-cell {
aspect-ratio: 1;
min-height: 40px;
border: 1px solid var(--ios-border);
border-radius: 8px;
background: var(--ios-elev);
color: var(--ios-text);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 4px 2px;
font: inherit;
}
.ios-month-cell--pad {
visibility: hidden;
pointer-events: none;
border: none;
background: transparent;
}
.ios-month-cell--today {
border-color: var(--ios-now);
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.35);
}
.ios-month-cell--has {
background: #1a1a24;
}
.ios-month-num {
font-size: 0.85rem;
font-weight: 600;
}
.ios-month-dots {
display: flex;
gap: 2px;
flex-wrap: wrap;
justify-content: center;
margin-top: 2px;
}
.ios-month-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: #60a5fa;
}
@media (max-width: 768px) {
.ios-form-grid {
grid-template-columns: 1fr;
}
.ios-calendar-head {
flex-direction: column;
align-items: flex-start;
}
.ios-event-card-head {
flex-direction: column;
align-items: flex-start;
}
.ios-agenda-toolbar {
flex-direction: column;
align-items: stretch;
}
.ios-agenda-segments {
justify-content: center;
}
.ios-agenda-nav {
justify-content: center;
}
}
+700
View File
@@ -0,0 +1,700 @@
const iosEventsList = document.getElementById("ios-events-list");
const iosSyncStatus = document.getElementById("ios-sync-status");
const iosForm = document.getElementById("ios-event-form");
const iosAgendaMount = document.getElementById("ios-agenda-mount");
const iosAgendaTitle = document.getElementById("ios-agenda-title");
const H_START = 6;
const H_END = 22;
const PX_PER_H = 44;
const TRACK_H = (H_END - H_START) * PX_PER_H;
let iosEventsCache = [];
/** @type {'day'|'week'|'month'} */
let iosViewMode = "week";
/** Ancre de navigation (jour courant dans la période affichée) */
let iosCursor = new Date();
let iosNowTimer = null;
let iosListVisible = false;
const PALETTE = [
"ios-cal-c0",
"ios-cal-c1",
"ios-cal-c2",
"ios-cal-c3",
"ios-cal-c4",
"ios-cal-c5",
];
const SUGGEST_HEX = ["#22c55e", "#3b82f6", "#8b5cf6", "#eab308", "#14b8a6", "#f43f5e"];
function escHtml(s) {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
function sqlToDatetimeLocal(s) {
if (!s) return "";
const m = /^(\d{4})-(\d{2})-(\d{2})[\sT](\d{2}):(\d{2})/.exec(String(s).trim());
if (!m) return "";
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}`;
}
function localDatetimeToSql(s) {
if (!s) return "";
const t = s.includes("T") ? s.replace("T", " ") : s;
return t.length === 16 ? `${t}:00` : t;
}
function parseEvtDate(iso) {
const d = new Date(String(iso).replace(" ", "T"));
return Number.isNaN(d.getTime()) ? null : d;
}
function darkenHex(hex, amount) {
const n = parseInt(hex.slice(1), 16);
const r = Math.max(0, (n >> 16) - amount);
const g = Math.max(0, ((n >> 8) & 0xff) - amount);
const b = Math.max(0, (n & 0xff) - amount);
return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
}
function suggestedColorForKey(key) {
const k = String(key || "");
let h = 0;
for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) >>> 0;
return SUGGEST_HEX[h % SUGGEST_HEX.length];
}
function colorClassForEvent(evt) {
const key = String(evt.calendar_source_url || evt.external_uid || evt.id || "");
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
/** Applique couleur perso (API) ou palette par défaut */
function styleEventBlock(el, evt) {
el.className = "ios-event-block";
el.style.background = "";
el.style.boxShadow = "";
const hex = evt.display_color;
if (hex && /^#[0-9A-Fa-f]{6}$/i.test(hex)) {
const c = hex.startsWith("#") ? hex : `#${hex.slice(-6)}`;
el.classList.add("ios-event-block--custom");
el.style.background = `linear-gradient(135deg, ${darkenHex(c, 40)} 0%, ${c} 100%)`;
el.style.boxShadow = "inset 3px 0 0 rgba(255,255,255,0.35)";
} else {
el.classList.add(colorClassForEvent(evt));
}
}
function mondayOf(date) {
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const dow = d.getDay();
const diff = dow === 0 ? -6 : 1 - dow;
d.setDate(d.getDate() + diff);
return d;
}
function addDays(d, n) {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
function addMonths(d, n) {
const x = new Date(d);
x.setMonth(x.getMonth() + n);
return x;
}
function sameLocalDay(a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
/** Segment d’un événement visible dans [dayMidnight, dayMidnight+1j[ */
function segmentInLocalDay(evt, dayMidnight) {
const s = parseEvtDate(evt.start_at);
const e = parseEvtDate(evt.end_at);
if (!s || !e) return null;
const dayEnd = addDays(dayMidnight, 1);
if (e <= dayMidnight || s >= dayEnd) return null;
const segStart = s > dayMidnight ? s : dayMidnight;
const segEnd = e < dayEnd ? e : dayEnd;
return { segStart, segEnd, evt };
}
function pxInTrackFromHourFraction(dayMidnight, t) {
const grid0 = new Date(dayMidnight);
grid0.setHours(H_START, 0, 0, 0);
const grid1 = new Date(dayMidnight);
grid1.setHours(H_END, 0, 0, 0);
const tt = Math.min(Math.max(t.getTime(), grid0.getTime()), grid1.getTime());
return ((tt - grid0.getTime()) / 3600000) * PX_PER_H;
}
function heightPxForSegment(dayMidnight, segStart, segEnd) {
const a = pxInTrackFromHourFraction(dayMidnight, segStart);
const b = pxInTrackFromHourFraction(dayMidnight, segEnd);
return Math.max(18, b - a);
}
function nowLineTopPx(colDay) {
const now = new Date();
if (!sameLocalDay(now, colDay)) return null;
return pxInTrackFromHourFraction(colDay, now);
}
function assignLanes(segments) {
if (segments.length === 0) return;
segments.sort((a, b) => a.segStart - b.segStart);
const laneEnds = [];
segments.forEach((seg) => {
let L = 0;
while (laneEnds[L] && seg.segStart < laneEnds[L]) L++;
laneEnds[L] = seg.segEnd;
seg._lane = L;
});
const maxL = segments.reduce((m, s) => Math.max(m, s._lane), 0);
segments.forEach((s) => {
s._laneCount = maxL + 1;
});
}
async function iosApi(action, options = {}) {
const method = options.method || "GET";
const headers = {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
...(options.headers || {}),
};
if (method !== "GET") {
headers["X-CSRF-Token"] = window.CSRF_TOKEN || "";
headers["Content-Type"] = "application/json";
}
const response = await fetch(`/modules/calendar-ios/api.php?action=${encodeURIComponent(action)}`, {
method,
credentials: "same-origin",
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const json = await response.json();
if (!json.ok) throw new Error(json.error || "Erreur inconnue");
return json.data;
}
function formatDate(iso) {
const d = parseEvtDate(iso);
return d ? d.toLocaleString("fr-FR") : iso;
}
function fillForm(evt) {
document.getElementById("ios-event-id").value = evt.id;
document.getElementById("ios-title").value = evt.title || "";
document.getElementById("ios-location").value = evt.location || "";
document.getElementById("ios-description").value = evt.description || "";
document.getElementById("ios-start").value = sqlToDatetimeLocal(evt.start_at);
document.getElementById("ios-end").value = sqlToDatetimeLocal(evt.end_at);
}
function resetForm() {
iosForm.reset();
document.getElementById("ios-event-id").value = "";
}
function eventsForLocalDay(y, m, d) {
return iosEventsCache.filter((evt) => {
const t = parseEvtDate(evt.start_at);
return t && t.getFullYear() === y && t.getMonth() === m && t.getDate() === d;
});
}
function updateToolbarTitle() {
if (!iosAgendaTitle) return;
if (iosViewMode === "day") {
iosAgendaTitle.textContent = iosCursor.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
return;
}
if (iosViewMode === "week") {
const mon = mondayOf(iosCursor);
const sun = addDays(mon, 6);
if (mon.getMonth() === sun.getMonth() && mon.getFullYear() === sun.getFullYear()) {
iosAgendaTitle.textContent = `${mon.getDate()} – ${sun.getDate()} ${sun.toLocaleDateString("fr-FR", { month: "long", year: "numeric" })}`;
} else {
iosAgendaTitle.textContent = `${mon.toLocaleDateString("fr-FR", { day: "numeric", month: "short" })} – ${sun.toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" })}`;
}
return;
}
iosAgendaTitle.textContent = new Date(iosCursor.getFullYear(), iosCursor.getMonth(), 1).toLocaleDateString("fr-FR", {
month: "long",
year: "numeric",
});
}
function setActiveSegments() {
document.querySelectorAll(".ios-seg").forEach((b) => {
b.classList.toggle("ios-seg--active", b.getAttribute("data-ios-view") === iosViewMode);
});
}
function renderTimeGutter() {
const frag = document.createDocumentFragment();
for (let h = H_START; h < H_END; h++) {
const row = document.createElement("div");
row.className = "ios-agenda-gutter-cell";
row.textContent = `${h} h`;
frag.appendChild(row);
}
return frag;
}
function buildDayColumn(dayMidnight) {
const col = document.createElement("div");
col.className = "ios-agenda-col";
const inner = document.createElement("div");
inner.className = "ios-agenda-col-inner";
inner.style.height = `${TRACK_H}px`;
const segs = [];
iosEventsCache.forEach((evt) => {
const seg = segmentInLocalDay(evt, dayMidnight);
if (seg) segs.push(seg);
});
assignLanes(segs);
segs.forEach((seg) => {
const top = pxInTrackFromHourFraction(dayMidnight, seg.segStart);
const h = heightPxForSegment(dayMidnight, seg.segStart, seg.segEnd);
const w = 100 / seg._laneCount;
const left = (100 * seg._lane) / seg._laneCount;
const blk = document.createElement("button");
blk.type = "button";
styleEventBlock(blk, seg.evt);
blk.style.top = `${top}px`;
blk.style.height = `${h}px`;
blk.style.left = `calc(${left}% + 2px)`;
blk.style.width = `calc(${w}% - 4px)`;
blk.innerHTML = `<span class="ios-event-block-title">${escHtml(seg.evt.title || "(Sans titre)")}</span>`;
blk.title = [seg.evt.title, seg.evt.location, seg.evt.calendar_source_url].filter(Boolean).join("\n");
blk.addEventListener("click", () => {
fillForm(seg.evt);
document.getElementById("ios-edit-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
});
inner.appendChild(blk);
});
col.appendChild(inner);
return col;
}
function renderWeekOrDay() {
if (!iosAgendaMount) return;
const isDay = iosViewMode === "day";
const weekStart = isDay ? new Date(iosCursor.getFullYear(), iosCursor.getMonth(), iosCursor.getDate()) : mondayOf(iosCursor);
const days = isDay ? [weekStart] : Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
const wrap = document.createElement("div");
wrap.className = "ios-week-wrap";
const head = document.createElement("div");
head.className = "ios-week-head" + (isDay ? " ios-week-head--day" : "");
const corner = document.createElement("div");
corner.className = "ios-week-corner";
head.appendChild(corner);
const today = new Date();
days.forEach((d) => {
const th = document.createElement("div");
th.className = "ios-week-head-day";
if (sameLocalDay(d, today)) th.classList.add("ios-week-head-day--today");
const num = document.createElement("span");
num.className = "ios-week-head-num";
num.textContent = String(d.getDate());
const wd = d.toLocaleDateString("fr-FR", { weekday: "short" }).replace(/\.$/, "");
th.appendChild(num);
th.appendChild(document.createTextNode(` ${wd}.`));
head.appendChild(th);
});
wrap.appendChild(head);
const scroll = document.createElement("div");
scroll.className = "ios-week-scroll";
const track = document.createElement("div");
track.className = "ios-week-track";
const gutter = document.createElement("div");
gutter.className = "ios-agenda-gutter";
gutter.appendChild(renderTimeGutter());
track.appendChild(gutter);
const colsWrap = document.createElement("div");
colsWrap.className = "ios-agenda-cols-wrap";
const cols = document.createElement("div");
cols.className = `ios-agenda-cols${isDay ? " ios-agenda-cols--day" : ""}`;
days.forEach((d0) => {
const dm = new Date(d0.getFullYear(), d0.getMonth(), d0.getDate());
cols.appendChild(buildDayColumn(dm));
});
colsWrap.appendChild(cols);
const todayCol = days.findIndex((d) => sameLocalDay(d, new Date()));
if (todayCol >= 0) {
const dm = new Date(days[todayCol].getFullYear(), days[todayCol].getMonth(), days[todayCol].getDate());
const nowTop = nowLineTopPx(dm);
if (nowTop !== null) {
const ov = document.createElement("div");
ov.className = "ios-now-overlay";
const line = document.createElement("div");
line.className = "ios-now-line-span";
line.style.top = `${nowTop}px`;
const lbl = document.createElement("div");
lbl.className = "ios-now-lbl-span";
lbl.textContent = new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
lbl.style.top = `${Math.max(2, nowTop - 10)}px`;
ov.appendChild(line);
ov.appendChild(lbl);
colsWrap.appendChild(ov);
}
}
track.appendChild(colsWrap);
scroll.appendChild(track);
wrap.appendChild(scroll);
iosAgendaMount.innerHTML = "";
iosAgendaMount.appendChild(wrap);
}
function renderMonthInAgenda() {
if (!iosAgendaMount) return;
const y = iosCursor.getFullYear();
const m = iosCursor.getMonth();
const first = new Date(y, m, 1);
const last = new Date(y, m + 1, 0);
let startWeekday = first.getDay() - 1;
if (startWeekday < 0) startWeekday = 6;
const wrap = document.createElement("div");
wrap.className = "ios-month-agenda";
const wk = document.createElement("div");
wk.className = "ios-month-agenda-weekdays";
["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].forEach((t) => {
const s = document.createElement("span");
s.textContent = t;
wk.appendChild(s);
});
wrap.appendChild(wk);
const grid = document.createElement("div");
grid.className = "ios-month-agenda-grid";
for (let i = 0; i < startWeekday; i++) {
const c = document.createElement("div");
c.className = "ios-month-cell ios-month-cell--pad";
grid.appendChild(c);
}
const today = new Date();
for (let d = 1; d <= last.getDate(); d++) {
const cell = document.createElement("button");
cell.type = "button";
cell.className = "ios-month-cell";
const list = eventsForLocalDay(y, m, d);
if (list.length) cell.classList.add("ios-month-cell--has");
if (y === today.getFullYear() && m === today.getMonth() && d === today.getDate()) {
cell.classList.add("ios-month-cell--today");
}
cell.innerHTML = `<span class="ios-month-num">${d}</span>`;
if (list.length) {
const dots = document.createElement("div");
dots.className = "ios-month-dots";
list.slice(0, 4).forEach(() => {
const dot = document.createElement("span");
dot.className = "ios-month-dot";
dots.appendChild(dot);
});
cell.appendChild(dots);
}
cell.addEventListener("click", () => {
iosViewMode = "day";
iosCursor = new Date(y, m, d);
setActiveSegments();
updateToolbarTitle();
renderAgenda();
});
grid.appendChild(cell);
}
wrap.appendChild(grid);
iosAgendaMount.innerHTML = "";
iosAgendaMount.appendChild(wrap);
}
function renderAgenda() {
updateToolbarTitle();
setActiveSegments();
if (iosViewMode === "month") {
renderMonthInAgenda();
} else {
renderWeekOrDay();
}
}
function startNowTimer() {
if (iosNowTimer) clearInterval(iosNowTimer);
iosNowTimer = setInterval(() => {
if (iosViewMode === "week" || iosViewMode === "day") renderAgenda();
}, 60000);
}
function showListSection(show) {
iosListVisible = !!show;
const sec = document.getElementById("ios-list-section");
if (!sec) return;
sec.style.display = show ? "block" : "none";
if (show && iosAgendaMount) {
iosAgendaMount.innerHTML =
'<p class="ios-agenda-list-hint">Vue liste ci-dessous. Utilise <strong>Jour</strong>, <strong>Semaine</strong> ou <strong>Mois</strong> pour retrouver l’agenda.</p>';
} else {
renderAgenda();
}
}
async function loadEvents() {
const events = await iosApi("events");
iosEventsCache = events;
if (iosEventsList) {
iosEventsList.innerHTML = "";
if (!events.length) {
iosEventsList.innerHTML = "<div class='ios-agenda-intro'>Aucun événement.</div>";
} else {
events.forEach((evt) => {
const card = document.createElement("div");
card.className = "ios-event-card";
if (evt.display_color && /^#[0-9A-Fa-f]{6}$/i.test(evt.display_color)) {
card.style.borderLeft = `4px solid ${evt.display_color}`;
}
card.innerHTML = `
<div class="ios-event-card-head">
<div>
<div class="ios-event-card-title">${escHtml(evt.title || "(Sans titre)")}</div>
<div class="ios-event-card-meta">${escHtml(formatDate(evt.start_at))} → ${escHtml(formatDate(evt.end_at))}</div>
<div class="ios-event-card-meta">${escHtml(evt.location || "")}</div>
</div>
<div class="ios-event-card-actions">
<button type="button" class="ios-agenda-btn" data-edit="${evt.id}">Modifier</button>
<button type="button" class="ios-agenda-btn" data-delete="${evt.id}">Supprimer</button>
</div>
</div>
`;
card.querySelector("[data-edit]").addEventListener("click", () => fillForm(evt));
card.querySelector("[data-delete]").addEventListener("click", async () => {
if (!confirm("Supprimer cet événement ?")) return;
await iosApi("events", { method: "DELETE", body: { id: evt.id } });
await loadEvents();
});
iosEventsList.appendChild(card);
});
}
}
if (!iosListVisible) {
renderAgenda();
}
}
async function loadSyncStatus() {
if (!iosSyncStatus) return;
const status = await iosApi("sync_status");
iosSyncStatus.textContent = status.message;
}
async function loadCalendarPrefs() {
const wrap = document.getElementById("ios-calendar-prefs-rows");
const msg = document.getElementById("ios-calendar-prefs-msg");
if (!wrap) return;
if (msg) msg.textContent = "";
wrap.innerHTML = "<span class=\"ios-agenda-intro\">Chargement des calendriers…</span>";
try {
const data = await iosApi("calendar_sources");
const rows = data.calendars || [];
wrap.innerHTML = "";
if (!rows.length) {
wrap.innerHTML =
"<span class=\"ios-agenda-intro\">Aucun calendrier détecté. Configure iCloud dans Paramètres puis synchronise.</span>";
return;
}
rows.forEach((row, idx) => {
const id = `ios-cal-cb-${idx}`;
const rowEl = document.createElement("div");
rowEl.className = "ios-cal-pref-row";
rowEl.dataset.urlKey = row.url_key;
const cb = document.createElement("input");
cb.type = "checkbox";
cb.id = id;
cb.checked = row.visible !== false;
const lab = document.createElement("label");
lab.className = "ios-cal-pref-label";
lab.htmlFor = id;
lab.textContent = row.display || row.url_key;
const colInp = document.createElement("input");
colInp.type = "color";
colInp.className = "ios-cal-pref-color";
colInp.value = row.color || suggestedColorForKey(row.url_key);
colInp.title = "Couleur dans l’agenda";
const autoBtn = document.createElement("button");
autoBtn.type = "button";
autoBtn.className = "ios-cal-pref-reset";
autoBtn.textContent = "Palette auto";
autoBtn.title = "Couleurs HouseHub par défaut (sans teinte iOS)";
autoBtn.addEventListener("click", () => {
rowEl.dataset.paletteAuto = "1";
colInp.disabled = true;
colInp.style.opacity = "0.35";
});
rowEl.appendChild(cb);
rowEl.appendChild(lab);
rowEl.appendChild(colInp);
rowEl.appendChild(autoBtn);
wrap.appendChild(rowEl);
});
} catch (e) {
wrap.innerHTML = `<span class="ios-agenda-intro">${escHtml(e.message)}</span>`;
}
}
async function saveCalendarPrefs() {
const msg = document.getElementById("ios-calendar-prefs-msg");
const wrap = document.getElementById("ios-calendar-prefs-rows");
if (!wrap) return;
const prefs = {};
wrap.querySelectorAll(".ios-cal-pref-row").forEach((rowEl) => {
const key = rowEl.dataset.urlKey;
if (!key) return;
const vis = rowEl.querySelector('input[type="checkbox"]')?.checked === true;
const colInp = rowEl.querySelector(".ios-cal-pref-color");
let color = null;
if (!rowEl.dataset.paletteAuto && colInp && !colInp.disabled) {
const v = colInp.value;
if (v && /^#[0-9A-Fa-f]{6}$/i.test(v)) {
color = v.startsWith("#") ? v.toLowerCase() : `#${v}`.toLowerCase();
}
}
prefs[key] = { visible: vis, color };
});
try {
await iosApi("calendar_prefs", { method: "POST", body: { prefs } });
if (msg) msg.textContent = "Préférences enregistrées.";
await loadEvents();
await loadCalendarPrefs();
} catch (e) {
if (msg) msg.textContent = e.message;
}
}
iosForm.addEventListener("submit", async (e) => {
e.preventDefault();
const idStr = document.getElementById("ios-event-id").value.trim();
const payload = {
title: document.getElementById("ios-title").value.trim(),
location: document.getElementById("ios-location").value.trim(),
description: document.getElementById("ios-description").value.trim(),
start_at: localDatetimeToSql(document.getElementById("ios-start").value),
end_at: localDatetimeToSql(document.getElementById("ios-end").value),
};
if (idStr) {
payload.id = parseInt(idStr, 10);
await iosApi("events", { method: "PUT", body: payload });
} else {
await iosApi("events", { method: "POST", body: payload });
}
resetForm();
await loadEvents();
});
document.getElementById("ios-form-reset")?.addEventListener("click", resetForm);
document.getElementById("ios-sync-btn")?.addEventListener("click", async () => {
const btn = document.getElementById("ios-sync-btn");
btn.disabled = true;
try {
const data = await iosApi("sync", { method: "POST", body: {} });
if (iosSyncStatus) iosSyncStatus.textContent = data.message;
await loadEvents();
await loadCalendarPrefs();
} finally {
btn.disabled = false;
}
});
document.querySelectorAll(".ios-seg").forEach((btn) => {
btn.addEventListener("click", () => {
iosViewMode = /** @type {'day'|'week'|'month'} */ (btn.getAttribute("data-ios-view"));
showListSection(false);
startNowTimer();
});
});
document.getElementById("ios-view-list")?.addEventListener("click", () => {
showListSection(true);
if (iosNowTimer) clearInterval(iosNowTimer);
});
document.getElementById("ios-agenda-prev")?.addEventListener("click", () => {
if (iosViewMode === "day") iosCursor = addDays(iosCursor, -1);
else if (iosViewMode === "week") iosCursor = addDays(iosCursor, -7);
else iosCursor = addMonths(iosCursor, -1);
renderAgenda();
});
document.getElementById("ios-agenda-next")?.addEventListener("click", () => {
if (iosViewMode === "day") iosCursor = addDays(iosCursor, 1);
else if (iosViewMode === "week") iosCursor = addDays(iosCursor, 7);
else iosCursor = addMonths(iosCursor, 1);
renderAgenda();
});
document.getElementById("ios-agenda-today")?.addEventListener("click", () => {
iosCursor = new Date();
showListSection(false);
startNowTimer();
});
document.getElementById("ios-agenda-add")?.addEventListener("click", () => {
resetForm();
const now = new Date();
now.setMinutes(Math.ceil(now.getMinutes() / 15) * 15, 0, 0);
const end = new Date(now.getTime() + 3600000);
const f = (d) => {
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
};
document.getElementById("ios-start").value = f(now);
document.getElementById("ios-end").value = f(end);
document.getElementById("ios-edit-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
document.getElementById("ios-title")?.focus();
});
document.getElementById("ios-calendar-prefs-save")?.addEventListener("click", () => saveCalendarPrefs());
window.addEventListener("DOMContentLoaded", async () => {
try {
iosCursor = new Date();
await loadEvents();
await loadCalendarPrefs();
await loadSyncStatus();
showListSection(false);
startNowTimer();
} catch (e) {
if (iosSyncStatus) iosSyncStatus.textContent = e.message;
if (iosAgendaMount) iosAgendaMount.innerHTML = `<p class="ios-agenda-intro">${escHtml(e.message)}</p>`;
}
});
+303
View File
@@ -0,0 +1,303 @@
<?php
require_once dirname(__DIR__, 2) . '/includes/crypto.php';
require_once dirname(__DIR__, 2) . '/includes/icloud_caldav.php';
/**
* Mot de passe normalisé + URL calendrier (découverte si racine iCloud).
*
* @return array{integration: array, password: string}
*/
function ios_caldav_prepare(array $integration, ?PDO $metaPdo = null): array
{
$password = hh_normalize_apple_app_password(hh_decrypt_secret($integration['secret_encrypted']));
$url = trim($integration['calendar_url'] ?? '');
$resolvedUrl = hh_icloud_resolve_calendar_url_if_needed($integration['username'], $password, $url);
$out = $integration;
$out['calendar_url'] = $resolvedUrl;
if ($metaPdo && !empty($integration['id']) && rtrim($url, '/') !== rtrim($resolvedUrl, '/')) {
$metaPdo->prepare('UPDATE user_calendar_integrations SET calendar_url = ? WHERE id = ?')
->execute([$resolvedUrl, $integration['id']]);
}
return ['integration' => $out, 'password' => $password];
}
function ios_make_ics(array $event): string
{
$uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');
$start = gmdate('Ymd\THis\Z', strtotime($event['start_at']));
$end = gmdate('Ymd\THis\Z', strtotime($event['end_at']));
$summary = addcslashes($event['title'] ?? '', ",;");
$description = addcslashes($event['description'] ?? '', ",;");
$location = addcslashes($event['location'] ?? '', ",;");
$updated = gmdate('Ymd\THis\Z');
return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//HouseHub//Calendar iOS//FR\r\nBEGIN:VEVENT\r\nUID:$uid\r\nDTSTAMP:$updated\r\nDTSTART:$start\r\nDTEND:$end\r\nSUMMARY:$summary\r\nDESCRIPTION:$description\r\nLOCATION:$location\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
}
function ios_caldav_request(string $url, string $username, string $password, string $method = 'GET', ?string $body = null, array $headers = []): array
{
$ch = curl_init($url);
$defaultCt = ($method === 'REPORT' || $method === 'PROPFIND')
? 'application/xml; charset=utf-8'
: 'text/calendar; charset=utf-8';
$requestHeaders = array_merge([
'Content-Type: ' . $defaultCt,
], $headers);
$timeout = ($method === 'REPORT' || $method === 'PROPFIND') ? 60 : 20;
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $username . ':' . $password,
CURLOPT_HTTPHEADER => $requestHeaders,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 10,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($ch);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw === false) {
return ['code' => 0, 'headers' => '', 'body' => ''];
}
return [
'code' => $code,
'headers' => substr($raw, 0, $headerSize),
'body' => substr($raw, $headerSize),
];
}
/**
* Déplie les lignes ICS pliées (RFC 5545).
*/
function ios_ics_unfold(string $ics): string
{
$ics = str_replace("\r\n", "\n", $ics);
return preg_replace("/\n[ \t]/", '', $ics);
}
/**
* @return list<array{external_uid:string,title:string,description:string,location:string,start_at:string,end_at:string}>
*/
function ios_parse_vevents_from_ics(string $ics): array
{
$ics = ios_ics_unfold($ics);
$events = [];
if (!preg_match_all('/BEGIN:VEVENT\s*(.*?)\s*END:VEVENT/s', $ics, $matches)) {
return [];
}
foreach ($matches[1] as $chunk) {
if (!preg_match('/^UID:([^\r\n]+)/m', $chunk, $uidM)) {
continue;
}
$uid = trim($uidM[1]);
$summary = '';
if (preg_match('/^SUMMARY:([^\r\n]*)/m', $chunk, $m)) {
$summary = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1])));
}
$description = '';
if (preg_match('/^DESCRIPTION:([^\r\n]*)/m', $chunk, $m)) {
$description = str_replace('\\,', ',', str_replace('\\;', ';', str_replace('\\n', "\n", trim($m[1]))));
}
$location = '';
if (preg_match('/^LOCATION:([^\r\n]*)/m', $chunk, $m)) {
$location = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1])));
}
$startRaw = null;
if (preg_match('/^DTSTART[^:]*:([^\r\n]+)/m', $chunk, $m)) {
$startRaw = trim($m[1]);
}
if ($startRaw === null || $startRaw === '') {
continue;
}
$endRaw = null;
if (preg_match('/^DTEND[^:]*:([^\r\n]+)/m', $chunk, $m)) {
$endRaw = trim($m[1]);
}
if (preg_match('/^\d{8}$/', $startRaw)) {
$startTs = strtotime($startRaw . 'T000000 UTC');
} else {
$norm = preg_replace('/^(\d{8})T(\d{6})Z?$/', '$1T$2 UTC', $startRaw);
$startTs = strtotime($norm ?: $startRaw);
if ($startTs === false) {
$startTs = strtotime($startRaw);
}
}
if ($startTs === false) {
continue;
}
if ($endRaw !== null && $endRaw !== '') {
if (preg_match('/^\d{8}$/', $endRaw)) {
$endTs = strtotime($endRaw . 'T000000 UTC');
} else {
$normE = preg_replace('/^(\d{8})T(\d{6})Z?$/', '$1T$2 UTC', $endRaw);
$endTs = strtotime($normE ?: $endRaw);
if ($endTs === false) {
$endTs = strtotime($endRaw);
}
}
} else {
$endTs = $startTs;
}
if ($endTs === false) {
$endTs = $startTs;
}
$events[] = [
'external_uid' => $uid,
'title' => $summary,
'description' => $description,
'location' => $location,
'start_at' => date('Y-m-d H:i:s', $startTs),
'end_at' => date('Y-m-d H:i:s', $endTs),
];
}
return $events;
}
/**
* Associe chaque fragment iCalendar du REPORT à l’URL de ressource CalDAV (nécessaire pour PUT/DELETE sur iCloud).
*
* @return list<array{resource_url:string,ical:string}>
*/
function ios_caldav_parse_report_calendar_fragments(string $xmlBody, string $calendarBaseUrl): array
{
$dom = new DOMDocument();
if (!@$dom->loadXML($xmlBody)) {
return [];
}
$xpath = new DOMXPath($dom);
$responses = $xpath->query("//*[local-name()='response']");
$out = [];
foreach ($responses as $resp) {
$hrefNodes = $xpath->query(".//*[local-name()='href']", $resp);
if ($hrefNodes->length === 0) {
continue;
}
$href = trim($hrefNodes->item(0)->textContent);
if ($href === '') {
continue;
}
$cd = $xpath->query(".//*[local-name()='calendar-data']", $resp);
if ($cd->length === 0) {
continue;
}
$ical = trim($cd->item(0)->textContent);
if ($ical === '' || stripos($ical, 'BEGIN:VEVENT') === false) {
continue;
}
$resourceUrl = (str_starts_with($href, 'http://') || str_starts_with($href, 'https://'))
? $href
: hh_caldav_resolve_href(rtrim($calendarBaseUrl, '/') . '/', $href);
$out[] = ['resource_url' => $resourceUrl, 'ical' => $ical];
}
return $out;
}
/**
* URLs des collections calendrier à interroger (tous les calendriers iCloud du compte, sinon l’URL configurée seule).
*
* @return list<string>
*/
function ios_sync_calendar_collection_urls(array $integration, string $password): array
{
$primary = rtrim($integration['calendar_url'] ?? '', '/') . '/';
if (hh_caldav_url_is_icloud($primary)) {
try {
$entries = hh_icloud_discover_calendar_entries($integration['username'], $password);
return array_values(array_unique(array_map(static function (array $e): string {
return rtrim($e['url'], '/') . '/';
}, $entries)));
} catch (Throwable $e) {
// compte partiellement lisible : au moins le calendrier principal
}
}
return [$primary];
}
/**
* @return list<array<string,mixed>> champs événement + _resource_url + _calendar_collection_url
*/
function ios_fetch_remote_events(array $integration, string $password): array
{
$calUrl = rtrim($integration['calendar_url'], '/') . '/';
$tStart = gmdate('Ymd\THis\Z', strtotime('-3 years'));
$tEnd = gmdate('Ymd\THis\Z', strtotime('+4 years'));
$reportXml = '<?xml version="1.0" encoding="UTF-8"?>'
. '<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">'
. '<D:prop><C:calendar-data/></D:prop>'
. '<C:filter>'
. '<C:comp-filter name="VCALENDAR">'
. '<C:comp-filter name="VEVENT">'
. '<C:time-range start="' . htmlspecialchars($tStart, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '" end="' . htmlspecialchars($tEnd, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '"/>'
. '</C:comp-filter></C:comp-filter></C:filter>'
. '</C:calendar-query>';
$res = ios_caldav_request($calUrl, $integration['username'], $password, 'REPORT', $reportXml, [
'Accept: application/xml, text/xml',
'Depth: 1',
]);
if (!in_array($res['code'], [200, 207], true)) {
throw new RuntimeException('Lecture CalDAV (REPORT) impossible (HTTP ' . $res['code'] . ').');
}
$fragments = ios_caldav_parse_report_calendar_fragments($res['body'], $calUrl);
$byUid = [];
foreach ($fragments as $frag) {
foreach (ios_parse_vevents_from_ics($frag['ical']) as $ev) {
$ev['_resource_url'] = $frag['resource_url'];
$ev['_calendar_collection_url'] = $calUrl;
$byUid[$ev['external_uid']] = $ev;
}
}
if ($byUid === [] && strpos($res['body'], 'multistatus') === false) {
$get = ios_caldav_request($calUrl, $integration['username'], $password, 'GET', null, ['Accept: text/calendar']);
if ($get['code'] >= 200 && $get['code'] < 400 && str_contains($get['body'], 'BEGIN:VEVENT')) {
foreach (ios_parse_vevents_from_ics($get['body']) as $ev) {
$ev['_resource_url'] = null;
$ev['_calendar_collection_url'] = $calUrl;
$byUid[$ev['external_uid']] = $ev;
}
}
}
return array_values($byUid);
}
/**
* Fusionne tous les calendriers du compte (iCloud) ou une seule collection.
*
* @return list<array<string,mixed>>
*/
function ios_fetch_remote_events_all_calendars(array $integration, string $password): array
{
$urls = ios_sync_calendar_collection_urls($integration, $password);
$merged = [];
foreach ($urls as $url) {
$sub = $integration;
$sub['calendar_url'] = $url;
foreach (ios_fetch_remote_events($sub, $password) as $ev) {
$merged[$ev['external_uid']] = $ev;
}
}
return array_values($merged);
}
function ios_push_event_to_remote(array $integration, array $event, string $password, ?string $resourceUrl = null, ?string $collectionUrlOverride = null): array
{
$uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');
$collection = rtrim($collectionUrlOverride ?? $integration['calendar_url'], '/');
$url = $resourceUrl ?: ($collection . '/' . rawurlencode($uid) . '.ics');
$ics = ios_make_ics($event);
return ios_caldav_request($url, $integration['username'], $password, 'PUT', $ics);
}
function ios_delete_remote_event(array $integration, string $externalUid, string $password, ?string $resourceUrl = null, ?string $collectionUrlOverride = null): array
{
$collection = rtrim($collectionUrlOverride ?? $integration['calendar_url'], '/');
$url = $resourceUrl ?: ($collection . '/' . rawurlencode($externalUid) . '.ics');
return ios_caldav_request($url, $integration['username'], $password, 'DELETE', null, ['Accept: */*']);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,208 @@
<?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, carry_over_max_days, carry_over_deadline_month 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'],
'carry_over_max_days' => $leave['carry_over_max_days'] ?? 0,
'carry_over_deadline_month' => $leave['carry_over_deadline_month'] ?? 12
];
}
$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 (Nettoyé des règles de report qui sont maintenant sur la personne)
$stmtLeaveTypes = $pdo->query("SELECT code, label, default_allowance, reset_month FROM pf_leave_types ORDER BY label ASC");
$leaveTypes = $stmtLeaveTypes->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'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(tr('error_occured'));
$mode = $_POST['mode'] ?? 'add';
$code = strtoupper(trim($_POST['code'] ?? ''));
$label = trim($_POST['label'] ?? '');
$carryMax = (float)($_POST['carry_over_max_days'] ?? 0);
$carryDeadline = (int)($_POST['carry_over_deadline_month'] ?? 12);
if (empty($code) || empty($label)) throw new Exception(tr('fc_err_code_label'));
if ($mode === 'add') {
$stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label, carry_over_max_days, carry_over_deadline_month) VALUES (?, ?, ?, ?)");
$stmt->execute([$code, $label, $carryMax, $carryDeadline]);
} else {
$stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ?, carry_over_max_days = ?, carry_over_deadline_month = ? WHERE code = ?");
$stmt->execute([$label, $carryMax, $carryDeadline, $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, carry_over_max_days, carry_over_deadline_month FROM pf_person_leave_meta WHERE person_id = ? ORDER BY leave_type ASC");
$stmt->execute([$personId]);
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
if ($action === 'add_person_leave') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$id = (int)($_POST['meta_id'] ?? 0); // Nouvel ID pour différencier l'Update de l'Insert
$personId = (int)($_POST['person_id'] ?? 0);
$type = strtoupper(trim($_POST['leave_type'] ?? ''));
$allowance = (float)($_POST['allowance'] ?? 0);
$resetMonth = (int)($_POST['reset_month'] ?? 1);
$carryMax = (float)($_POST['carry_over_max_days'] ?? 0);
$carryDeadline = (int)($_POST['carry_over_deadline_month'] ?? 12);
$method = in_array($_POST['method'] ?? '', ['FIXED', 'ACCUMULATED']) ? $_POST['method'] : 'FIXED';
if ($personId <= 0 || empty($type)) throw new Exception("Données invalides.");
// On construit la date anniversaire avec le mois choisi par l'utilisateur
$anniversaryDate = "2000-" . str_pad((string)$resetMonth, 2, "0", STR_PAD_LEFT) . "-01";
if ($id > 0) {
// ✏️ UPDATE (Modification)
$stmt = $pdo->prepare("UPDATE pf_person_leave_meta SET allowance = ?, method = ?, anniversary_date = ?, carry_over_max_days = ?, carry_over_deadline_month = ? WHERE id = ? AND person_id = ?");
$stmt->execute([$allowance, $method, $anniversaryDate, $carryMax, $carryDeadline, $id, $personId]);
} else {
// ➕ INSERT (Ajout)
// Vérification des doublons
$check = $pdo->prepare("SELECT id FROM pf_person_leave_meta WHERE person_id = ? AND leave_type = ?");
$check->execute([$personId, $type]);
if ($check->rowCount() > 0) throw new Exception("Ce congé est déjà attribué à cette personne.");
$stmt = $pdo->prepare("INSERT INTO pf_person_leave_meta (person_id, leave_type, allowance, method, anniversary_date, carry_over_max_days, carry_over_deadline_month) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$personId, $type, $allowance, $method, $anniversaryDate, $carryMax, $carryDeadline]);
}
echo json_encode(['success' => true]);
exit;
}
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(),
]); ]);
} }
?>
@@ -4,7 +4,7 @@ require __DIR__ . '/../../../../includes/db.php';
try { try {
$stmt = $pdo->query(" $stmt = $pdo->query("
SELECT person_id, leave_type, snapshot_date, remaining_balance SELECT person_id, leave_type, snapshot_date, remaining_balance, carry_over_balance
FROM pf_leave_snapshots FROM pf_leave_snapshots
ORDER BY snapshot_date ASC ORDER BY snapshot_date ASC
"); ");
@@ -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
$pdo->rollBack(); if (isset($pdo) && $pdo->inTransaction()) {
$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()
]);
}
?>
@@ -15,13 +15,16 @@ try {
$stmtDel = $pdo->prepare("DELETE FROM pf_leave_snapshots WHERE person_id = ? AND leave_type = ? AND snapshot_date = ?"); $stmtDel = $pdo->prepare("DELETE FROM pf_leave_snapshots WHERE person_id = ? AND leave_type = ? AND snapshot_date = ?");
$stmtDel->execute([$input['person_id'], $input['leave_type'], $input['snapshot_date']]); $stmtDel->execute([$input['person_id'], $input['leave_type'], $input['snapshot_date']]);
$carry_over_balance = isset($input['carry_over_balance']) ? (float)$input['carry_over_balance'] : 0.00;
// Étape 2 : On insère le nouveau solde // Étape 2 : On insère le nouveau solde
$stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance) VALUES (?, ?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance, carry_over_balance) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([ $stmt->execute([
$input['person_id'], $input['person_id'],
$input['leave_type'], $input['leave_type'],
$input['snapshot_date'], $input['snapshot_date'],
$input['remaining_balance'] $input['remaining_balance'],
$carry_over_balance
]); ]);
echo json_encode(['status' => 'success']); echo json_encode(['status' => 'success']);
File diff suppressed because it is too large. Load diff
+410
View File
@@ -0,0 +1,410 @@
<?php
// modules/food/includes/api/api-liste.php
ob_start();
require_once dirname(__DIR__, 4) . '/includes/auth.php';
require_login();
header('Content-Type: application/json');
set_exception_handler(function (\Throwable $e) {
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
});
require_once dirname(__DIR__, 4) . '/includes/db.php';
// ── Auto-create tables ────────────────────────────────────────────────────────
$pdo->exec("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");
$pdo->exec("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");
$pdo->exec("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");
$pdo->exec("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");
$pdo->exec("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");
// Migrations
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 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 ──────────────────────────────────────────────────
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) {
$cats = [
['🍎','Fruits & Légumes'],['🍖','Viande'],['🐟','Poissonnerie'],
['🍞','Boulangerie'],['🥛','Frais'],['❄️','Surgelés'],
['🧃','Boissons'],['🌾','Pâtes, Riz, Féculents'],['🥜','Épicerie salée'],
['🥫','Conserves'],['🍛','Plats cuisinés'],['🧂','Sauces & Condiments'],
['🥐','Petit déjeuner'],['🍪','Biscuits & Gâteaux'],['🍫','Confiserie'],
['🍦','Dessert'],['🧴','Beauté & Hygiène'],['🍼','Bébé'],
['🧼','Entretien'],['🐶','Animaux'],['🏡','Maison & Jardin'],['💊','Pharmacie'],
];
$ins = $pdo->prepare("INSERT INTO pf_list_categories (icon, name, position) VALUES (?,?,?)");
foreach ($cats as $i => $c) $ins->execute([$c[0], $c[1], $i * 10]);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function liste_normalize(string $s): string {
return mb_strtolower(trim(mb_substr($s, 0, 500)));
}
function liste_normalize_detect(string $s): string {
$s = mb_strtolower(trim($s));
$from = ['é','è','ê','ë','à','â','ä','ù','û','ü','î','ï','ô','ö','ç','œ','æ'];
$to = ['e','e','e','e','a','a','a','u','u','u','i','i','o','o','c','oe','ae'];
$s = str_replace($from, $to, $s);
return trim(preg_replace('/[^a-z0-9 ]/', '', $s));
}
function liste_touch_history(PDO $pdo, string $label): void {
$hash = hash('sha256', liste_normalize($label));
$pdo->prepare("INSERT INTO pf_grocery_history (label_hash, label_display) VALUES (?,?)
ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()")
->execute([$hash, trim($label)]);
}
function liste_ensure_default(PDO $pdo): int {
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn() === 0) {
$pdo->exec("INSERT INTO pf_lists (name, position) VALUES ('Ma liste', 0)");
return (int)$pdo->lastInsertId();
}
return (int)$pdo->query("SELECT id FROM pf_lists ORDER BY position, id LIMIT 1")->fetchColumn();
}
function liste_builtin_detect(string $normalized): ?int {
static $map = null;
if ($map === null) $map = [
'pomme'=>1,'poire'=>1,'banane'=>1,'orange'=>1,'citron'=>1,'raisin'=>1,
'fraise'=>1,'framboise'=>1,'myrtille'=>1,'cerise'=>1,'peche'=>1,'abricot'=>1,
'mangue'=>1,'kiwi'=>1,'ananas'=>1,'melon'=>1,'pasteque'=>1,'prune'=>1,
'tomate'=>1,'concombre'=>1,'carotte'=>1,'oignon'=>1,'poireau'=>1,
'courgette'=>1,'aubergine'=>1,'poivron'=>1,'salade'=>1,'laitue'=>1,
'epinard'=>1,'brocoli'=>1,'chou'=>1,'choux'=>1,'haricot'=>1,'pois'=>1,
'champignon'=>1,'ail'=>1,'echalote'=>1,'pomme de terre'=>1,'patate'=>1,
'navet'=>1,'radis'=>1,'betterave'=>1,'fenouil'=>1,'asperge'=>1,
'artichaut'=>1,'celeri'=>1,'butternut'=>1,'courge'=>1,'potiron'=>1,
'gingembre'=>1,'avocat'=>1,'endive'=>1,'ciboulette'=>1,'persil'=>1,
'basilic'=>1,'menthe'=>1,'thym'=>1,'romarin'=>1,'coriandre'=>1,
'girofle'=>1,'citron vert'=>1,'lime'=>1,'pamplemousse'=>1,'litchi'=>1,
'boeuf'=>2,'veau'=>2,'poulet'=>2,'porc'=>2,'agneau'=>2,'dinde'=>2,
'lapin'=>2,'jambon'=>2,'lardon'=>2,'saucisse'=>2,'saucisson'=>2,
'merguez'=>2,'chipolata'=>2,'steak'=>2,'escalope'=>2,'roti'=>2,
'gigot'=>2,'filet'=>2,'cuisses'=>2,'viande hachee'=>2,'andouille'=>2,
'magret'=>2,'canard'=>2,'foie'=>2,'boudin'=>2,'paupiette'=>2,
'entrecote'=>2,'cote de boeuf'=>2,'onglet'=>2,'bavette'=>2,
'saumon'=>3,'thon'=>3,'dorade'=>3,'bar'=>3,'cabillaud'=>3,'sole'=>3,
'truite'=>3,'lieu'=>3,'maquereau'=>3,'sardine'=>3,'anchois'=>3,
'crevette'=>3,'moule'=>3,'huitre'=>3,'coquille'=>3,'poulpe'=>3,
'seiche'=>3,'langoustine'=>3,'lotte'=>3,'merlu'=>3,'colin'=>3,
'tilapia'=>3,'daurade'=>3,'aiglefin'=>3,'homard'=>3,'crabe'=>3,
'pain'=>4,'baguette'=>4,'brioche'=>4,'croissant'=>4,'pain de mie'=>4,
'ficelle'=>4,'fougasse'=>4,'ciabatta'=>4,'biscotte'=>4,'naan'=>4,
'pita'=>4,'toast'=>4,'bagel'=>4,'pain burger'=>4,'wrap'=>4,
'lait'=>5,'creme'=>5,'yaourt'=>5,'yourt'=>5,'beurre'=>5,
'fromage'=>5,'camembert'=>5,'brie'=>5,'comte'=>5,'gruyere'=>5,
'emmental'=>5,'mozzarella'=>5,'ricotta'=>5,'mascarpone'=>5,
'feta'=>5,'chevre'=>5,'oeuf'=>5,'oeufs'=>5,'creme fraiche'=>5,
'fromage blanc'=>5,'petit suisse'=>5,'kefir'=>5,
'surgele'=>6,'pizza surgelee'=>6,'frite surgelee'=>6,'poisson pane'=>6,
'legume surgele'=>6,'glace'=>6,'sorbet'=>6,'plat surgele'=>6,
'nugget'=>6,'edamame'=>6,'petits pois'=>6,'epinards'=>6,
'eau'=>7,'coca'=>7,'cola'=>7,'limonade'=>7,'sirop'=>7,
'biere'=>7,'vin'=>7,'champagne'=>7,'cafe'=>7,'the'=>7,
'tisane'=>7,'soda'=>7,'jus'=>7,'kombucha'=>7,'cidre'=>7,
'whisky'=>7,'rhum'=>7,'vodka'=>7,'prosecco'=>7,'rose'=>7,
'pate'=>8,'spaghetti'=>8,'tagliatelle'=>8,'penne'=>8,'fusilli'=>8,
'macaroni'=>8,'riz'=>8,'couscous'=>8,'quinoa'=>8,'lentille'=>8,
'pois chiche'=>8,'farine'=>8,'semoule'=>8,'polenta'=>8,'boulgour'=>8,
'orge'=>8,'feculent'=>8,'vermicelle'=>8,'nouille'=>8,'gnocchi'=>8,
'chips'=>9,'crackers'=>9,'olives'=>9,'cornichon'=>9,'capres'=>9,
'tapenade'=>9,'houmous'=>9,'tzatziki'=>9,'sel'=>9,'poivre'=>9,
'epice'=>9,'cube bouillon'=>9,'bouillon'=>9,'noix de cajou'=>9,
'amande'=>9,'pistache'=>9,'noix'=>9,'cacahuete'=>9,
'conserve'=>10,'boite de tomate'=>10,'concentre de tomate'=>10,
'mais'=>10,'ratatouille'=>10,'cassoulet'=>10,'pate de foie'=>10,
'sardine boite'=>10,'thon boite'=>10,'maquereau boite'=>10,
'soupe boite'=>10,'haricot boite'=>10,
'quiche'=>11,'lasagne'=>11,'gratin'=>11,'pizza'=>11,'tartiflette'=>11,
'croque'=>11,'hachis parmentier'=>11,'paella'=>11,'moussaka'=>11,
'tarte'=>11,'flamiche'=>11,'pissaladiere'=>11,
'ketchup'=>12,'mayonnaise'=>12,'moutarde'=>12,'vinaigrette'=>12,
'sauce soja'=>12,'tabasco'=>12,'huile'=>12,'vinaigre'=>12,
'sauce tomate'=>12,'pesto'=>12,'sriracha'=>12,'worcester'=>12,
'nuoc mam'=>12,'sauce'=>12,'condiment'=>12,'worcestershire'=>12,
'cereale'=>13,'muesli'=>13,'granola'=>13,'corn flakes'=>13,
'miel'=>13,'confiture'=>13,'nutella'=>13,'beurre de cacahuete'=>13,
'sirop d erable'=>13,'sirop erable'=>13,'chocolat en poudre'=>13,
'biscuit'=>14,'gateau'=>14,'cookie'=>14,'madeleine'=>14,
'financier'=>14,'quatre quarts'=>14,'brownie'=>14,'sable'=>14,
'speculoos'=>14,'oreo'=>14,'lu'=>14,'petit beurre'=>14,'galette'=>14,
'palmier'=>14,'macaron'=>14,'eclair'=>14,
'chocolat'=>15,'bonbon'=>15,'reglisse'=>15,'caramel'=>15,
'nougat'=>15,'marshmallow'=>15,'guimauve'=>15,'sucette'=>15,
'chewing gum'=>15,'pastille'=>15,'calisson'=>15,
'yaourt dessert'=>16,'creme dessert'=>16,'mousse au chocolat'=>16,
'tiramisu'=>16,'creme brulee'=>16,'flan'=>16,'crepe'=>16,
'profiterole'=>16,
'shampoing'=>17,'gel douche'=>17,'savon'=>17,'dentifrice'=>17,
'deodorant'=>17,'crème visage'=>17,'crème corps'=>17,'rasoir'=>17,
'mousse a raser'=>17,'coton'=>17,'lingette'=>17,'maquillage'=>17,
'parfum'=>17,'brosse a dents'=>17,'fil dentaire'=>17,'serum'=>17,
'hydratant'=>17,'demaquillant'=>17,
'couche'=>18,'biberon'=>18,'lait infantile'=>18,'compote bebe'=>18,
'pot bebe'=>18,'puree bebe'=>18,'lingette bebe'=>18,'savon bebe'=>18,
'creme bebe'=>18,'sucette bebe'=>18,
'lessive'=>19,'liquide vaisselle'=>19,'nettoyant'=>19,'degraissant'=>19,
'deboucheur'=>19,'anticalcaire'=>19,'eponge'=>19,'serpillere'=>19,
'sac poubelle'=>19,'papier toilette'=>19,'sopalin'=>19,'essuie tout'=>19,
'aluminium'=>19,'film plastique'=>19,'sac congelation'=>19,
'nettoyant wc'=>19,'desinfectant'=>19,'vitre'=>19,'javel'=>19,
'croquette'=>20,'patee'=>20,'litiere'=>20,'os'=>20,
'friandise animale'=>20,'nourriture chat'=>20,'nourriture chien'=>20,
'ampoule'=>21,'pile'=>21,'bougie'=>21,'allumette'=>21,
'ruban adhesif'=>21,'colle'=>21,'vis'=>21,'terreau'=>21,
'engrais'=>21,'pot de fleur'=>21,'arrosoir'=>21,'graine'=>21,
'tournevis'=>21,'marteau'=>21,'clou'=>21,'cle'=>21,
'doliprane'=>22,'ibuprofene'=>22,'paracetamol'=>22,'aspirine'=>22,
'bandage'=>22,'pansement'=>22,'thermometre'=>22,'serum physiologique'=>22,
'vitamine'=>22,'complement'=>22,'masque'=>22,'gant medical'=>22,
'antihistaminique'=>22,'antidouleur'=>22,
];
if (isset($map[$normalized])) return $map[$normalized];
$words = explode(' ', $normalized);
foreach ($words as $w) {
if (strlen($w) >= 3 && isset($map[$w])) return $map[$w];
}
foreach ($map as $kw => $catPos) {
if (str_contains($normalized, $kw)) return $catPos;
}
return null;
}
function liste_detect_category(PDO $pdo, string $label): ?int {
$norm = liste_normalize_detect($label);
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
$r->execute([$norm]);
if ($row = $r->fetch()) return (int)$row['category_id'];
foreach (explode(' ', $norm) as $w) {
if (strlen($w) < 3) continue;
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
$r->execute([$w]);
if ($row = $r->fetch()) return (int)$row['category_id'];
}
$pos = liste_builtin_detect($norm);
if ($pos === null) return null;
$r = $pdo->query("SELECT id FROM pf_list_categories ORDER BY position, id");
$ids = $r->fetchAll(PDO::FETCH_COLUMN);
return $ids[$pos - 1] ?? null;
}
function liste_learn(PDO $pdo, string $label, int $category_id): void {
$norm = liste_normalize_detect($label);
if (!$norm) return;
$pdo->prepare("INSERT INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)
ON DUPLICATE KEY UPDATE category_id=VALUES(category_id)")
->execute([$norm, $category_id]);
foreach (explode(' ', $norm) as $w) {
if (strlen($w) >= 4) {
$pdo->prepare("INSERT IGNORE INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)")
->execute([$w, $category_id]);
}
}
}
$action = $_GET['action'] ?? $_POST['action'] ?? '';
$method = $_SERVER['REQUEST_METHOD'];
$body = [];
if ($method === 'PUT' || $method === 'POST') {
$raw = file_get_contents('php://input');
if ($raw) $body = json_decode($raw, true) ?? [];
foreach ($_POST as $k => $v) if (!isset($body[$k])) $body[$k] = $v;
}
// ── CATEGORIES ────────────────────────────────────────────────────────────────
if ($action === 'categories' && $method === 'GET') {
$rows = $pdo->query("SELECT id, icon, name, position FROM pf_list_categories ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['categories' => $rows]);
exit;
}
// ── SET CATEGORY (+ learn) ────────────────────────────────────────────────────
if ($action === 'set_category' && $method === 'POST') {
$item_id = (int)($body['item_id'] ?? 0);
$cat_id_raw = $body['category_id'] ?? null;
$category_id = ($cat_id_raw === null || $cat_id_raw === '' || $cat_id_raw === '0') ? null : (int)$cat_id_raw;
if (!$item_id) { http_response_code(400); echo json_encode(['error' => 'item_id required']); exit; }
$pdo->prepare("UPDATE pf_grocery_items SET category_id=? WHERE id=?")->execute([$category_id, $item_id]);
if ($category_id !== null) {
$row = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?");
$row->execute([$item_id]);
if ($r = $row->fetch()) liste_learn($pdo, $r['label'], $category_id);
}
echo json_encode(['ok' => true]);
exit;
}
// ── LISTS ─────────────────────────────────────────────────────────────────────
if ($action === 'lists') {
if ($method === 'GET') {
$firstId = liste_ensure_default($pdo);
$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]);
exit;
}
if ($method === 'POST') {
$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; }
$pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn();
$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, 'color' => $color, 'list_type' => $list_type, 'position' => $pos]);
exit;
}
if ($method === 'PUT') {
$id = (int)($_GET['id'] ?? 0);
$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; }
$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;
}
if ($method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0);
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn() <= 1) {
http_response_code(400); echo json_encode(['error' => 'cannot delete last list']); exit;
}
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$id]);
$pdo->prepare("DELETE FROM pf_lists WHERE id=?")->execute([$id]);
echo json_encode(['ok' => true]); exit;
}
}
// ── ITEMS ─────────────────────────────────────────────────────────────────────
if ($action === 'items') {
$list_id = (int)($_GET['list_id'] ?? $body['list_id'] ?? 0);
if ($method === 'GET') {
if (!$list_id) { http_response_code(400); echo json_encode(['error' => 'list_id required']); exit; }
$s = $pdo->prepare("SELECT id, list_id, category_id, label, in_cart, position FROM pf_grocery_items WHERE list_id=? ORDER BY in_cart, position, id");
$s->execute([$list_id]);
echo json_encode(['items' => $s->fetchAll(PDO::FETCH_ASSOC)]); exit;
}
if ($method === 'POST') {
$label = trim($body['label'] ?? '');
if (!$list_id || !$label) { http_response_code(400); echo json_encode(['error' => 'missing fields']); exit; }
$norm = liste_normalize($label);
$dup = $pdo->prepare("SELECT COUNT(*) FROM pf_grocery_items WHERE list_id=? AND LOWER(TRIM(label))=?");
$dup->execute([$list_id, $norm]);
if ((int)$dup->fetchColumn() > 0) { echo json_encode(['duplicate' => true]); exit; }
$q = $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM pf_grocery_items WHERE list_id=?");
$q->execute([$list_id]);
$pos = (int)$q->fetchColumn();
$category_id = liste_detect_category($pdo, $label);
$pdo->prepare("INSERT INTO pf_grocery_items (list_id, category_id, label, in_cart, position) VALUES (?,?,?,0,?)")->execute([$list_id, $category_id, $label, $pos]);
$id = (int)$pdo->lastInsertId();
liste_touch_history($pdo, $label);
echo json_encode(['id' => $id, 'list_id' => $list_id, 'category_id' => $category_id, 'label' => $label, 'in_cart' => 0, 'position' => $pos]); exit;
}
if ($method === 'PUT') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
if (isset($body['in_cart'])) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=? WHERE id=?")->execute([(int)$body['in_cart'], $id]);
if (isset($body['label'])) {
$label = trim($body['label']);
if ($label) {
$pdo->prepare("UPDATE pf_grocery_items SET label=? WHERE id=?")->execute([$label, $id]);
liste_touch_history($pdo, $label);
}
}
echo json_encode(['ok' => true]); exit;
}
if ($method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
$r = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?"); $r->execute([$id]);
if ($row = $r->fetch()) liste_touch_history($pdo, $row['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE id=?")->execute([$id]);
echo json_encode(['ok' => true]); exit;
}
}
// ── BULK ──────────────────────────────────────────────────────────────────────
if ($action === 'uncheck_all' && $method === 'POST') {
$lid = (int)($body['list_id'] ?? 0);
if ($lid) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=0 WHERE list_id=?")->execute([$lid]);
echo json_encode(['ok' => true]); exit;
}
if ($action === 'delete_picked' && $method === 'POST') {
$lid = (int)($body['list_id'] ?? 0);
if ($lid) {
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=? AND in_cart=1"); $rows->execute([$lid]);
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=? AND in_cart=1")->execute([$lid]);
}
echo json_encode(['ok' => true]); exit;
}
if ($action === 'clear_all' && $method === 'POST') {
$lid = (int)($body['list_id'] ?? 0);
if ($lid) {
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=?"); $rows->execute([$lid]);
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$lid]);
}
echo json_encode(['ok' => true]); exit;
}
// ── HISTORY ───────────────────────────────────────────────────────────────────
if ($action === 'history' && $method === 'GET') {
$max = 20;
$n = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'"); $n->execute();
if ($r = $n->fetch()) $max = max(1, min(50, (int)$r['content']));
$s = $pdo->query("SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT $max");
echo json_encode(['history' => $s->fetchAll(PDO::FETCH_COLUMN)]); exit;
}
http_response_code(400);
echo json_encode(['error' => 'unknown action: ' . $action]);
+73
View File
@@ -0,0 +1,73 @@
<?php
// Désactive l'affichage des erreurs HTML pour garantir un retour JSON strict
ini_set('display_errors', 0);
error_reporting(0);
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require dirname(__DIR__, 4) . '/includes/i18n.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
try {
$dateParam = $_GET['date'] ?? date('Y-m-d');
$baseDate = new DateTime($dateParam);
// Aligner sur le lundi de la semaine
$monday = clone $baseDate;
if ($monday->format('N') != 1) {
$monday->modify('last monday');
}
$sunday = clone $monday;
$sunday->modify('+6 days');
$weekData = [
'week_label' => tr('meal_week') . ' ' . $monday->format('W'),
'days' => []
];
// Extraction des repas de la base (person_id = 0 pour le foyer global)
$stmt = $pdo->prepare("SELECT plan_date, service, meal_name FROM pf_meals_plan WHERE plan_date BETWEEN ? AND ? AND person_id = 0");
$stmt->execute([$monday->format('Y-m-d'), $sunday->format('Y-m-d')]);
$meals = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$meals[$row['plan_date']][$row['service']] = $row['meal_name'];
}
$current = clone $monday;
// Mappage vers les clés i18n existantes de HouseHub
$daysMap = [
1 => 'day_mon', 2 => 'day_tue', 3 => 'day_wed',
4 => 'day_thu', 5 => 'day_fri', 6 => 'day_sat', 7 => 'day_sun'
];
for ($i = 0; $i < 7; $i++) {
$dStr = $current->format('Y-m-d');
$dayNum = $current->format('N'); // 1 à 7
$monthNum = $current->format('m'); // 01 à 12
$dayDate = $current->format('d'); // 01 à 31
$dayName = tr($daysMap[$dayNum]);
$monthName = tr('month_' . $monthNum);
$weekData['days'][] = [
'date' => $dStr,
'day_name' => $dayName,
'date_formatted' => $dayDate . ' ' . $monthName,
'lunch' => $meals[$dStr]['lunch'] ?? '',
'dinner' => $meals[$dStr]['dinner'] ?? ''
];
$current->modify('+1 day');
}
echo json_encode(['success' => true, 'data' => $weekData], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+277
View File
@@ -0,0 +1,277 @@
<?php
// modules/meals/includes/api/import-pdf.php
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_FILES['pdf_file'])) {
echo json_encode(['success' => false, 'error' => 'Aucun fichier reçu.']);
exit;
}
$personId = (int)$_POST['person_id'];
if ($personId <= 0) {
echo json_encode(['success' => false, 'error' => 'Enfant non spécifié.']);
exit;
}
// ---------------------------------------------------------
// PARSEUR PDF SPATIAL NATIF (100% PHP SANS LIBRAIRIE)
// ---------------------------------------------------------
class NativePdfParser {
public static function getMealData($filename) {
$content = file_get_contents($filename);
$texts = [];
// 1. Décompression et Tokenization Mathématique
if (preg_match_all('/stream(.*?)endstream/is', $content, $matches)) {
foreach ($matches[1] as $stream) {
$stream = ltrim($stream, "\r\n");
$decoded = @gzuncompress($stream);
if (!$decoded) $decoded = $stream;
// On éclate le flux par opérateurs PDF clés (Position et Texte)
$parts = preg_split('/(Tm|Td|Tj|TJ)/', $decoded, -1, PREG_SPLIT_DELIM_CAPTURE);
$x = 0; $y = 0;
for ($i = 0; $i < count($parts); $i++) {
$token = $parts[$i];
if ($token === 'Tm' || $token === 'Td') {
$prev = $parts[$i-1];
preg_match_all('/[0-9.-]+/', $prev, $nums);
if (count($nums[0]) >= 2) {
$yRaw = array_pop($nums[0]);
$xRaw = array_pop($nums[0]);
if ($token === 'Tm') {
$x = (float)$xRaw; $y = (float)$yRaw;
} else {
$x += (float)$xRaw; $y += (float)$yRaw;
}
}
} elseif ($token === 'Tj') {
$prev = $parts[$i-1];
if (preg_match('/\((.*?)\)$/', trim($prev), $m)) {
self::addText($texts, $m[1], $x, $y);
}
} elseif ($token === 'TJ') {
$prev = $parts[$i-1];
if (preg_match('/\[(.*?)\]$/', trim($prev), $m)) {
preg_match_all('/\((.*?)\)/', $m[1], $tjs);
$tjX = $x;
foreach ($tjs[1] as $tj) {
self::addText($texts, $tj, $tjX, $y);
$tjX += 10; // Décalage estimé pour les mots suivants du tableau
}
}
}
}
}
}
if (empty($texts)) throw new Exception("Impossible de lire le contenu du PDF. Le fichier est peut-être crypté ou corrompu.");
// 2. Définition des Colonnes Verticles (X) via les En-têtes de Jour
$dayLabels = ['LUNDI', 'MARDI', 'MERCREDI', 'JEUDI', 'VENDREDI'];
$colXs = [];
foreach ($texts as $t) {
$clean = strtoupper(preg_replace('/[^A-Z]/', '', $t['text']));
if (in_array($clean, $dayLabels)) {
$colXs[$clean] = $t['x'];
}
}
// Sécurité si les jours exacts ne sont pas détectés à cause de la police
if (count($colXs) < 5) {
$allXs = array_column($texts, 'x');
sort($allXs);
$minX = $allXs[0] ?? 0;
$maxX = end($allXs) ?? 500;
$step = ($maxX - $minX) / 5;
$colXs = [
'LUNDI' => $minX + $step * 0.5,
'MARDI' => $minX + $step * 1.5,
'MERCREDI' => $minX + $step * 2.5,
'JEUDI' => $minX + $step * 3.5,
'VENDREDI' => $minX + $step * 4.5,
];
}
$sortedXs = array_values($colXs);
sort($sortedXs);
// 3. Définition des Tranches Horizontales (Y) via les Titres de Rangée (Zone Extrême Gauche)
$rowHeaders = [];
foreach ($texts as $t) {
$clean = strtoupper(preg_replace('/[^A-Z]/', '', $t['text']));
if (in_array($clean, ['HORSDEUVRE', 'DECLINAISON', 'PLATCHAUD', 'GARNITURE', 'PRODUITLAITIER', 'DESSERT', 'LESGOUTERS'])) {
// On s'assure de ne capter que la colonne tout à gauche
if ($t['x'] < ($sortedXs[0] - 20)) {
$rowHeaders[] = ['label' => $clean, 'y' => $t['y']];
}
}
}
// Tri de haut en bas (Y du PDF est décroissant du haut vers le bas)
usort($rowHeaders, fn($a, $b) => $b['y'] <=> $a['y']);
$yPlatTop = null; $yPlatBot = null;
$yGarTop = null; $yGarBot = null;
for ($i = 0; $i < count($rowHeaders); $i++) {
if ($rowHeaders[$i]['label'] === 'PLATCHAUD') {
$yPlatTop = $rowHeaders[$i]['y'];
// La limite basse est le label suivant, ou -50 unités
$yPlatBot = $rowHeaders[$i+1]['y'] ?? ($yPlatTop - 50);
}
if ($rowHeaders[$i]['label'] === 'GARNITURE') {
$yGarTop = $rowHeaders[$i]['y'];
$yGarBot = $rowHeaders[$i+1]['y'] ?? ($yGarTop - 50);
}
}
if (!$yPlatTop || !$yGarTop) {
throw new Exception("Structure du menu non reconnue (Mots-clés 'PLAT CHAUD' et 'GARNITURE' introuvables).");
}
// 4. Affectation Chirurgicale par Intersection Grille (X, Y)
$days = [[], [], [], [], []];
foreach ($texts as $t) {
$cleanAlpha = strtoupper(preg_replace('/[^A-Z]/', '', $t['text']));
// Rejet des labels génériques et de la fameuse ligne "Déclinaison"
if (in_array($cleanAlpha, ['DECLINAISON', 'MENUVEGETARIEN', 'PLATCHAUD', 'GARNITURE', 'PRODUITLAITIER', 'HORSDEUVRE', 'LESGOUTERS', 'DESSERT'])) continue;
// Retrait des labels BIO/HVE et mentions superflues
$textVal = trim(preg_replace('/\b(AB|HVE|MSC|Label Rouge)\b/i', '', $t['text']));
$textVal = str_replace(['(plat', 'complet)'], '', $textVal);
$textVal = trim(preg_replace('/[^a-zA-ZÀ-ÿ0-9\s&\-]/u', '', $textVal));
if (strlen($textVal) < 3) continue;
// Filtre Vertical : Est-on dans la tranche Plat Chaud ou Garniture ? (Tolérance +/- 5 unités)
$inPlat = ($t['y'] <= $yPlatTop + 5 && $t['y'] >= $yPlatBot + 5);
$inGar = ($t['y'] <= $yGarTop + 5 && $t['y'] >= $yGarBot + 5);
if ($inPlat || $inGar) {
// Filtre Horizontal : Quel est le jour le plus proche ?
$bestCol = 0;
$minDiff = PHP_FLOAT_MAX;
foreach ($sortedXs as $idx => $cx) {
$diff = abs($t['x'] - $cx);
if ($diff < $minDiff) {
$minDiff = $diff;
$bestCol = $idx;
}
}
$days[$bestCol][] = ['text' => $textVal, 'y' => $t['y'], 'type' => $inPlat ? 'plat' : 'gar'];
}
}
// 5. Concaténation Finale
$finalDays = [];
for ($i = 0; $i < 5; $i++) {
$plats = array_filter($days[$i], fn($item) => $item['type'] === 'plat');
$gars = array_filter($days[$i], fn($item) => $item['type'] === 'gar');
usort($plats, fn($a, $b) => $b['y'] <=> $a['y']);
usort($gars, fn($a, $b) => $b['y'] <=> $a['y']);
$platText = self::concatLines($plats);
$garText = self::concatLines($gars);
$full = trim("$platText + $garText", " +");
$finalDays[] = preg_replace('/\s+/', ' ', $full);
}
return $finalDays;
}
private static function concatLines($items) {
$lines = [];
$currentLine = "";
$lastY = null;
foreach ($items as $item) {
// Si le décalage Y est trop fort, on considère que c'est une nouvelle ligne
if ($lastY !== null && abs($lastY - $item['y']) > 8) {
if ($currentLine) $lines[] = trim($currentLine);
$currentLine = $item['text'];
} else {
$currentLine .= " " . $item['text'];
}
$lastY = $item['y'];
}
if ($currentLine) $lines[] = trim($currentLine);
return implode(' ', array_filter($lines, fn($l) => strlen(trim($l)) > 2));
}
private static function addText(&$texts, $rawText, $x, $y) {
// Décodage de l'octal \123
$m = preg_replace_callback('/\\\\([0-7]{1,3})/', function($cb) { return chr(octdec($cb[1])); }, $rawText);
// Détection de l'UTF-16BE (Le BOM \xFE\xFF indique que le texte est double encodé)
if (str_starts_with($m, "\xFE\xFF")) {
$m = mb_convert_encoding(substr($m, 2), 'UTF-8', 'UTF-16BE');
} else {
// Fallback Latin-1 si pas de BOM
$m = mb_convert_encoding($m, 'UTF-8', 'ISO-8859-1');
}
// Suppression radicale des octets nuls responsables de l'espacement forcé (E s c a l o p e)
$m = str_replace("\0", "", $m);
$m = trim(preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\+&\'’\-]/u', '', $m));
if ($m !== '') {
$texts[] = ['text' => $m, 'x' => $x, 'y' => $y];
}
}
}
try {
$filename = $_FILES['pdf_file']['name'];
// 1. Extraction robuste de la date depuis le nom de fichier (Menus-du-14-au-20-septembre-2026.pdf)
$mondayDate = null;
if (preg_match('/(\d{1,2})[\s-]+au[\s-]+\d{1,2}[\s-]+([a-zèéû]+)[\s-]+(\d{4})/i', $filename, $m)) {
$monthsMap = ['janvier'=>'01','fevrier'=>'02','février'=>'02','mars'=>'03','avril'=>'04','mai'=>'05','juin'=>'06','juillet'=>'07','aout'=>'08','août'=>'08','septembre'=>'09','octobre'=>'10','novembre'=>'11','decembre'=>'12','décembre'=>'12'];
$month = $monthsMap[strtolower($m[2])] ?? '09';
$mondayDate = $m[3] . '-' . $month . '-' . str_pad($m[1], 2, '0', STR_PAD_LEFT);
}
if (!$mondayDate) {
echo json_encode(['success' => false, 'error' => 'Veuillez nommer le fichier "Menus-du-14-au-20-septembre-2026.pdf".']);
exit;
}
// 2. Lancement de l'Analyse Spatiale
$plats = NativePdfParser::getMealData($_FILES['pdf_file']['tmp_name']);
if (empty($plats) || count($plats) !== 5) {
echo json_encode(['success' => false, 'error' => 'Erreur lors du découpage des 5 colonnes.']);
exit;
}
// 3. Insertion en BDD (Lundi au Vendredi)
$pdo->beginTransaction();
$stmt = $pdo->prepare("
INSERT INTO pf_meals_plan (plan_date, service, person_id, meal_name)
VALUES (?, 'lunch', ?, ?)
ON DUPLICATE KEY UPDATE meal_name = VALUES(meal_name)
");
$currentDate = new DateTime($mondayDate);
for ($i = 0; $i < 5; $i++) {
$fullMeal = $plats[$i];
if (!empty($fullMeal) && $fullMeal !== '+') {
$stmt->execute([$currentDate->format('Y-m-d'), $personId, $fullMeal]);
}
$currentDate->modify('+1 day');
}
$pdo->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+56
View File
@@ -0,0 +1,56 @@
<?php
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'error' => 'Méthode non autorisée']);
exit;
}
$action = $_POST['action'] ?? '';
try {
if ($action === 'save_cell') {
$date = $_POST['plan_date'];
$service = $_POST['service'];
$person_id = (int)$_POST['person_id'];
$meal_name = trim($_POST['meal_name']);
if (empty($meal_name)) {
$stmt = $pdo->prepare("DELETE FROM pf_meals_plan WHERE plan_date=? AND service=? AND person_id=?");
$stmt->execute([$date, $service, $person_id]);
} else {
$stmt = $pdo->prepare("INSERT INTO pf_meals_plan (plan_date, service, person_id, meal_name) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE meal_name = VALUES(meal_name)");
$stmt->execute([$date, $service, $person_id, $meal_name]);
}
echo json_encode(['success' => true]);
exit;
}
if ($action === 'save_bulk') {
$updates = json_decode($_POST['updates'], true);
if (is_array($updates)) {
$pdo->beginTransaction();
$stmtDel = $pdo->prepare("DELETE FROM pf_meals_plan WHERE plan_date=? AND service=? AND person_id=?");
$stmtIns = $pdo->prepare("INSERT INTO pf_meals_plan (plan_date, service, person_id, meal_name) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE meal_name = VALUES(meal_name)");
foreach ($updates as $u) {
if (empty(trim($u['meal_name']))) {
$stmtDel->execute([$u['plan_date'], $u['service'], $u['person_id']]);
} else {
$stmtIns->execute([$u['plan_date'], $u['service'], $u['person_id'], trim($u['meal_name'])]);
}
}
$pdo->commit();
}
echo json_encode(['success' => true]);
exit;
}
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+567
View File
@@ -0,0 +1,567 @@
<?php
// modules/food/views/liste.php
if (!isset($pdo)) { exit; }
?>
<div class="liste-main">
<!-- Tabs des listes -->
<div class="liste-tabs-bar">
<div class="liste-tabs" id="liste-tabs"></div>
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
</div>
<!-- Corps de la liste active -->
<div class="liste-body" id="liste-body">
<div class="liste-add-card">
<input type="text" id="liste-input" maxlength="500" placeholder="<?= htmlspecialchars(tr('liste_placeholder_add')) ?>" autocomplete="off">
<button id="btn-liste-add"><?= htmlspecialchars(tr('liste_btn_add')) ?></button>
</div>
<div class="liste-toolbar" id="liste-toolbar">
<button class="btn-tool" id="btn-uncheck-all"><?= htmlspecialchars(tr('liste_btn_uncheck_all')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-delete-picked"><?= htmlspecialchars(tr('liste_btn_delete_picked')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-clear-all"><?= htmlspecialchars(tr('liste_btn_clear_all')) ?></button>
</div>
<div id="liste-items-root"></div>
<div id="liste-history-root"></div>
</div>
</div>
<script>
const LISTE_TRANSLATIONS = {
already_in: <?= json_encode(tr('liste_already_in')) ?>,
confirm_clear: <?= json_encode(tr('liste_confirm_clear')) ?>,
confirm_delete_list: <?= json_encode(tr('liste_confirm_delete_list')) ?>,
rename_list: <?= json_encode(tr('liste_rename_list')) ?>,
new_name: <?= json_encode(tr('liste_new_name')) ?>,
list_deleted: <?= json_encode(tr('liste_list_deleted')) ?>,
list_created: <?= json_encode(tr('liste_list_created')) ?>,
list_renamed: <?= json_encode(tr('liste_list_renamed')) ?>,
edit_placeholder: <?= json_encode(tr('liste_edit_placeholder')) ?>,
history_title: <?= json_encode(tr('liste_history_title')) ?>,
added: <?= json_encode(tr('liste_added')) ?>,
updated: <?= json_encode(tr('liste_updated')) ?>,
deleted: <?= json_encode(tr('liste_deleted')) ?>,
cancel: <?= json_encode(tr('cancel')) ?>,
};
// ⚠️ NOUVEAU CHEMIN D'API
const API = '/modules/food/includes/api/api-liste.php';
const T = window.LISTE_TRANSLATIONS || {};
const state = {
lists: [],
currentListId: null,
items: [],
history: [],
categories: [],
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 ──────────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function toast(msg, type = 'info') {
let c = document.getElementById('liste-toast-container');
if (!c) {
c = document.createElement('div');
c.id = 'liste-toast-container';
c.className = 'liste-toast-container';
document.body.appendChild(c);
}
const t = document.createElement('div');
t.className = `liste-toast liste-toast-${type}`;
t.textContent = msg;
c.appendChild(t);
setTimeout(() => t.classList.add('show'), 10);
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 2500);
}
async function api(action, method = 'GET', body = null, params = {}) {
const url = new URL(API, location.origin);
url.searchParams.set('action', action);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
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 ─────────────────────────────────────────────────────────────────
async function loadCategories() {
if (state.categories.length) return;
const r = await api('categories');
state.categories = r.categories || [];
state.catMap = {};
state.categories.forEach(c => { state.catMap[c.id] = c; });
}
let activePicker = null;
function openCategoryPicker(itemId, anchorEl) {
closeCategoryPicker();
const item = state.items.find(i => i.id === itemId);
if (!item) return;
const picker = document.createElement('div');
picker.className = 'liste-cat-picker';
picker.innerHTML = `
<div class="liste-cat-picker-title">Catégorie</div>
<div class="liste-cat-picker-list">
<button class="liste-cat-picker-item${!item.category_id ? ' active' : ''}" data-cat="0">🏷️ Non classé</button>
${state.categories.map(c =>
`<button class="liste-cat-picker-item${item.category_id == c.id ? ' active' : ''}" data-cat="${c.id}">${esc(c.icon)} ${esc(c.name)}</button>`
).join('')}
</div>`;
const rect = anchorEl.getBoundingClientRect();
let top = rect.bottom + 4;
let left = rect.left;
if (left + 220 > window.innerWidth) left = window.innerWidth - 228;
if (top + 340 > window.innerHeight) top = rect.top - Math.min(340, top + 340 - window.innerHeight) - 4;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
document.body.appendChild(picker);
activePicker = picker;
picker.querySelectorAll('.liste-cat-picker-item').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const catId = parseInt(btn.dataset.cat) || null;
await api('set_category', 'POST', { item_id: itemId, category_id: catId });
const it = state.items.find(i => i.id === itemId);
if (it) it.category_id = catId;
closeCategoryPicker();
renderItems();
});
});
setTimeout(() => {
document.addEventListener('click', closeCategoryPicker, { once: true });
}, 0);
}
function closeCategoryPicker() {
if (activePicker) { activePicker.remove(); activePicker = null; }
}
// ── Tabs ───────────────────────────────────────────────────────────────────────
function typeEmoji(list_type) {
return LIST_TYPES.find(t => t.value === list_type)?.emoji || '';
}
function renderTabs() {
const container = document.getElementById('liste-tabs');
if (!container) return;
container.innerHTML = '';
state.lists.forEach(list => {
const isActive = list.id === state.currentListId;
const tab = document.createElement('div');
tab.className = 'liste-tab' + (isActive ? ' active' : '');
tab.dataset.id = list.id;
// 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 = `
${prefix}
<span class="liste-tab-name">${esc(list.name)}</span>
<button class="liste-tab-btn liste-tab-edit" title="Modifier" data-id="${list.id}">✏</button>
`;
} else {
tab.innerHTML = `${prefix}<span class="liste-tab-name">${esc(list.name)}</span>`;
tab.addEventListener('click', () => switchList(list.id));
}
container.appendChild(tab);
});
container.querySelectorAll('.liste-tab-edit').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); openListModal(parseInt(btn.dataset.id)); });
});
}
// ── List management ─────────────────────────────────────────────────────────────
document.getElementById('btn-add-list')?.addEventListener('click', () => openListModal(null));
async function deleteList(listId) {
if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return;
const r = await api('lists', 'DELETE', null, { id: listId });
if (r.error) { toast(r.error, 'error'); return; }
state.lists = state.lists.filter(l => l.id !== listId);
if (state.currentListId === listId) {
state.currentListId = state.lists[0]?.id ?? null;
}
await loadItems();
renderTabs();
toast(T.list_deleted || 'Liste supprimée');
}
// ── Switch list ─────────────────────────────────────────────────────────────────
async function switchList(listId) {
state.currentListId = listId;
await loadItems();
renderTabs();
}
// ── Items ───────────────────────────────────────────────────────────────────────
async function loadItems() {
if (!state.currentListId) { renderItems(); return; }
const r = await api('items', 'GET', null, { list_id: state.currentListId });
state.items = r.items || [];
renderItems();
}
function renderItems() {
const root = document.getElementById('liste-items-root');
if (!root) return;
const toolbar = document.getElementById('liste-toolbar');
if (!state.currentListId || state.items.length === 0) {
root.innerHTML = state.currentListId
? `<div class="liste-empty-items"><span>📝</span><p>Liste vide — ajoutez un article ci-dessus.</p></div>`
: '';
if (toolbar) toolbar.style.display = 'none';
return;
}
if (toolbar) toolbar.style.display = '';
const pending = state.items.filter(i => !i.in_cart);
const inCart = state.items.filter(i => i.in_cart);
const hasCategories = pending.some(i => i.category_id);
let html = '';
if (hasCategories && state.categories.length) {
const groups = new Map();
pending.forEach(item => {
const key = item.category_id ?? 0;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item);
});
const catIds = state.categories.map(c => parseInt(c.id));
const orderedKeys = catIds.filter(id => groups.has(id));
if (groups.has(0)) orderedKeys.push(0);
orderedKeys.forEach(key => {
const items = groups.get(key);
const cat = key ? state.catMap[key] : null;
const label = cat ? `${cat.icon} ${cat.name}` : '🏷️ Non classé';
html += `<div class="liste-cat-section-header">${esc(label)} <span class="liste-cat-section-count">${items.length}</span></div>`;
html += items.map(rowHtml).join('');
});
} else {
html = pending.map(rowHtml).join('');
}
if (inCart.length) {
html += `<div class="liste-cart-divider">Dans le panier (${inCart.length})</div>` + inCart.map(rowHtml).join('');
}
root.innerHTML = html;
root.querySelectorAll('.liste-check').forEach(cb => {
cb.addEventListener('change', () => toggleCart(parseInt(cb.dataset.id), cb.checked ? 1 : 0));
});
root.querySelectorAll('.btn-edit-item').forEach(btn => {
btn.addEventListener('click', () => editItem(parseInt(btn.dataset.id)));
});
root.querySelectorAll('.btn-delete-item').forEach(btn => {
btn.addEventListener('click', () => deleteItem(parseInt(btn.dataset.id)));
});
root.querySelectorAll('.liste-cat-badge').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); openCategoryPicker(parseInt(btn.dataset.id), btn); });
});
}
function rowHtml(item) {
const checked = item.in_cart ? 'checked' : '';
const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row';
const cat = item.category_id ? state.catMap[item.category_id] : null;
const badgeCls = 'liste-cat-badge' + (cat ? '' : ' liste-cat-badge-empty');
const badgeIcon = cat ? esc(cat.icon) : '🏷️';
const badgeTip = cat ? esc(cat.name) : 'Non classé';
return `<div class="${cls}" data-id="${item.id}">
<button class="${badgeCls}" data-id="${item.id}" title="${badgeTip}">${badgeIcon}</button>
<label class="liste-check-label">
<input type="checkbox" class="liste-check" data-id="${item.id}" ${checked}>
<span class="liste-label">${esc(item.label)}</span>
</label>
<div class="liste-row-actions">
<button class="btn-edit-item" data-id="${item.id}" title="Modifier">✏</button>
<button class="btn-delete-item" data-id="${item.id}" title="Supprimer">🗑</button>
</div>
</div>`;
}
async function addItem(label) {
const r = await api('items', 'POST', { list_id: state.currentListId, label });
if (r.duplicate) { toast(T.already_in || 'Déjà dans la liste', 'warn'); return; }
if (r.id) {
state.items.push(r);
renderItems();
renderHistory();
toast(T.added || 'Ajouté');
}
}
async function toggleCart(id, next) {
await api('items', 'PUT', { in_cart: next }, { id });
const item = state.items.find(i => i.id === id);
if (item) item.in_cart = next;
renderItems();
}
async function editItem(id) {
const item = state.items.find(i => i.id === id);
if (!item) return;
const newLabel = prompt(T.edit_placeholder || 'Modifier :', item.label);
if (newLabel === null || newLabel.trim() === '') return;
await api('items', 'PUT', { label: newLabel.trim() }, { id });
item.label = newLabel.trim();
renderItems();
toast(T.updated || 'Modifié');
}
async function deleteItem(id) {
await api('items', 'DELETE', null, { id });
state.items = state.items.filter(i => i.id !== id);
renderItems();
toast(T.deleted || 'Supprimé');
}
// ── Toolbar ────────────────────────────────────────────────────────────────────
document.getElementById('btn-uncheck-all')?.addEventListener('click', async () => {
if (!state.currentListId) return;
await api('uncheck_all', 'POST', { list_id: state.currentListId });
state.items.forEach(i => i.in_cart = 0);
renderItems();
});
document.getElementById('btn-delete-picked')?.addEventListener('click', async () => {
if (!state.currentListId) return;
await api('delete_picked', 'POST', { list_id: state.currentListId });
state.items = state.items.filter(i => !i.in_cart);
renderItems();
toast(T.deleted || 'Supprimés');
});
document.getElementById('btn-clear-all')?.addEventListener('click', async () => {
if (!confirm(T.confirm_clear || 'Vider toute la liste ?')) return;
await api('clear_all', 'POST', { list_id: state.currentListId });
state.items = [];
renderItems();
toast(T.deleted || 'Liste vidée');
});
// ── Add input ───────────────────────────────────────────────────────────────────
document.getElementById('btn-liste-add')?.addEventListener('click', () => {
const input = document.getElementById('liste-input');
const v = input?.value.trim();
if (v) { addItem(v); input.value = ''; input.focus(); }
});
document.getElementById('liste-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('btn-liste-add')?.click();
});
// ── History ────────────────────────────────────────────────────────────────────
async function loadHistory() {
const r = await api('history');
state.history = r.history || [];
renderHistory();
}
function renderHistory() {
const root = document.getElementById('liste-history-root');
if (!root || !state.history.length) { if (root) root.innerHTML = ''; return; }
const currentLabels = new Set(state.items.map(i => i.label.toLowerCase().trim()));
const suggestions = state.history.filter(h => !currentLabels.has(h.toLowerCase().trim()));
if (!suggestions.length) { root.innerHTML = ''; return; }
root.innerHTML = `
<div class="liste-history-card">
<div class="liste-history-title">${T.history_title || 'Récemment utilisés'}</div>
<div class="liste-history-chips">
${suggestions.map(h => `<button class="liste-chip" data-label="${esc(h)}">${esc(h)}</button>`).join('')}
</div>
</div>`;
root.querySelectorAll('.liste-chip').forEach(btn => {
btn.addEventListener('click', () => {
if (state.currentListId) addItem(btn.dataset.label);
});
});
}
// ── Init ────────────────────────────────────────────────────────────────────────
async function init() {
const [listsR] = await Promise.all([api('lists'), loadCategories()]);
state.lists = listsR.lists || [];
state.currentListId = listsR.default_id || state.lists[0]?.id || null;
renderTabs();
await Promise.all([loadItems(), loadHistory()]);
}
init();
</script>
+357
View File
@@ -0,0 +1,357 @@
<?php
// modules/meals/main.php
// Récupération stricte des membres du foyer (Exclusion des intervenants et proches)
$stmt = $pdo->query("SELECT id, name FROM pf_people WHERE role NOT IN ('helper', 'nounou', 'proche_adulte', 'proche_enfant', 'relative') AND is_active = 1 ORDER BY id ASC");
$members = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($members)) {
echo "<div class='pf-alert pf-alert--warning'>" . tr('meals_no_members') . "</div>";
return;
}
// Calcul des 14 jours (Lundi semaine courante -> Dimanche semaine prochaine)
$start_date = new DateTime();
$start_date->modify('monday this week');
$dates = [];
for ($i = 0; $i < 14; $i++) {
$d = clone $start_date;
$d->modify("+$i days");
$dates[] = $d->format('Y-m-d');
}
$stmtMeals = $pdo->prepare("SELECT plan_date, service, person_id, meal_name FROM pf_meals_plan WHERE plan_date BETWEEN ? AND ?");
$stmtMeals->execute([$dates[0], end($dates)]);
$mealsData = [];
while ($row = $stmtMeals->fetch(PDO::FETCH_ASSOC)) {
$mealsData[$row['plan_date']][$row['service']][$row['person_id']] = $row['meal_name'];
}
?>
<div class="pf-container meals-container">
<div class="meals-header-bar">
<h1 class="meals-title">
<span class="meals-title-icon">🍽️</span>
<span data-i18n="meals_board_title"><?= tr('meals_board_title') ?></span>
</h1>
<div class="meals-actions">
<!-- NOUVEAU : Bouton Import PDF -->
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('importMenuModal').classList.add('open'); document.body.classList.add('no-scroll');">
📄 <?= tr('meal_btn_import_pdf') ?>
</button>
</div>
</div>
<!-- Barre contextuelle (Fixe en bas) -->
<div class="context-toolbar" id="contextToolbar">
<button class="ctx-btn" onmousedown="event.preventDefault(); applyToAllInRow()">➡️ <span data-i18n="btn_apply_all"><?= tr('btn_apply_all') ?></span></button>
<button class="ctx-btn primary" onmousedown="event.preventDefault(); openCopySheetFromCell()">🔄 <span data-i18n="btn_copy_to"><?= tr('btn_copy_to') ?></span></button>
</div>
<div class="meals-wrapper">
<?php
$weeks = array_chunk($dates, 7);
foreach ($weeks as $weekIndex => $weekDates):
$startWeekStr = date('d/m', strtotime($weekDates[0]));
$endWeekStr = date('d/m', strtotime(end($weekDates)));
?>
<h2 class="meals-week-title"><?= tr('meal_week') ?> (<?= $startWeekStr ?> - <?= $endWeekStr ?>)</h2>
<div class="meals-table-container">
<table class="meals-table" id="mealsTable_<?= $weekIndex ?>">
<thead>
<tr>
<th class="col-sticky"></th>
<?php foreach ($members as $m): ?>
<th><?= htmlspecialchars(strtoupper($m['name'])) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($weekDates as $dateStr):
$dayKey = strtolower(date('D', strtotime($dateStr)));
$dayName = tr('day_' . $dayKey);
$dayDate = date('d/m', strtotime($dateStr));
$fullDayName = tr('day_full_' . $dayKey) . ' ' . $dayDate;
?>
<tr class="row-lunch">
<td class="col-sticky">
<span class="day-label"><?= $dayName ?> <?= $dayDate ?></span>
<div class="service-icon" onmousedown="event.preventDefault(); openCopySheetFromRow('<?= $dateStr ?>', 'lunch', '<?= addslashes($fullDayName) ?>')" title="<?= tr('meals_copy_lunch') ?>">☀️</div>
</td>
<?php foreach ($members as $m):
$val = $mealsData[$dateStr]['lunch'][$m['id']] ?? '';
?>
<td class="meal-input-cell">
<textarea class="meal-input js-meal-input" data-d="<?= $dateStr ?>" data-s="lunch" data-p="<?= $m['id'] ?>" placeholder="-"><?= htmlspecialchars($val) ?></textarea>
</td>
<?php endforeach; ?>
</tr>
<tr class="row-dinner">
<td class="col-sticky">
<span class="day-label invisible"><?= $dayName ?> <?= $dayDate ?></span>
<div class="service-icon" onmousedown="event.preventDefault(); openCopySheetFromRow('<?= $dateStr ?>', 'dinner', '<?= addslashes($fullDayName) ?>')" title="<?= tr('meals_copy_dinner') ?>">🌙</div>
</td>
<?php foreach ($members as $m):
$val = $mealsData[$dateStr]['dinner'][$m['id']] ?? '';
?>
<td class="meal-input-cell">
<textarea class="meal-input js-meal-input" data-d="<?= $dateStr ?>" data-s="dinner" data-p="<?= $m['id'] ?>" placeholder="-"><?= htmlspecialchars($val) ?></textarea>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Bottom Sheet de Copie (Format Grille Ultra-compacte) -->
<div id="copyModal" class="pf-modal pf-bottom-sheet">
<div class="pf-modal-content pf-bs-content copy-modal-large">
<div class="bs-header">
<h3 class="bs-title">🔄 <span data-i18n="modal_copy_title"><?= tr('modal_copy_title') ?></span></h3>
<button class="bs-close" onclick="closeCopyModal()">&times;</button>
</div>
<div id="copySourceLabel" class="meals-copy-source">Source : ...</div>
<div class="week-section-title" data-i18n="week_current"><?= tr('week_current') ?></div>
<div class="copy-days-grid">
<?php for($i=0; $i<7; $i++): $dStr = $dates[$i]; $dName = tr('day_full_' . strtolower(date('D', strtotime($dStr)))) . ' ' . date('d/m', strtotime($dStr)); ?>
<div class="copy-day-card">
<div class="copy-day-name"><?= $dName ?></div>
<div class="copy-day-actions">
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'lunch')" title="<?= tr('meal_lunch') ?>">☀️</button>
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'dinner')" title="<?= tr('meal_dinner') ?>">🌙</button>
</div>
</div>
<?php endfor; ?>
</div>
<div class="week-section-title week-section-mt" data-i18n="week_next"><?= tr('week_next') ?></div>
<div class="copy-days-grid">
<?php for($i=7; $i<14; $i++): $dStr = $dates[$i]; $dName = tr('day_full_' . strtolower(date('D', strtotime($dStr)))) . ' ' . date('d/m', strtotime($dStr)); ?>
<div class="copy-day-card">
<div class="copy-day-name"><?= $dName ?></div>
<div class="copy-day-actions">
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'lunch')" title="<?= tr('meal_lunch') ?>">☀️</button>
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'dinner')" title="<?= tr('meal_dinner') ?>">🌙</button>
</div>
</div>
<?php endfor; ?>
</div>
</div>
</div>
<!-- NOUVEAU : Modale d'import PDF Cantine -->
<div id="importMenuModal" class="pf-modal">
<div class="pf-modal-content">
<div class="pf-modal-header" style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-light); padding-bottom: 15px; margin-bottom: 20px;">
<h3 class="pf-modal-title" style="margin: 0; border: none; padding: 0;">📄 <?= tr('meal_import_title') ?></h3>
<button type="button" class="pf-modal-close" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--text-muted);" onclick="document.getElementById('importMenuModal').classList.remove('open'); document.body.classList.remove('no-scroll');">&times;</button>
</div>
<form id="importMenuForm" onsubmit="importPdfMenu(event)" action="/modules/food/includes/api/import-pdf.php" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<div class="pf-form-group">
<label class="pf-label"><?= tr('meal_import_child') ?></label>
<select name="person_id" class="pf-input" required>
<option value="" disabled selected>-- <?= tr('meal_select_child') ?> --</option>
<?php foreach ($members as $m): ?>
<option value="<?= $m['id'] ?>"><?= htmlspecialchars($m['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="pf-form-group">
<label class="pf-label"><?= tr('meal_pdf_file') ?></label>
<div class="meal-dropzone" onclick="document.getElementById('canteenPdf').click()">
<span class="meal-dropzone-icon">📥</span>
<span id="pdfFileName"><?= tr('meal_drop_pdf') ?></span>
<input type="file" id="canteenPdf" name="pdf_file" accept=".pdf" style="display: none;" required onchange="document.getElementById('pdfFileName').innerText = this.files[0] ? this.files[0].name : '<?= addslashes(tr('meal_drop_pdf')) ?>';">
</div>
</div>
<div class="modal-footer">
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('importMenuModal').classList.remove('open'); document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn" id="btnImportPdf"><?= tr('btn_import') ?></button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Sauvegarde automatique AJAX à chaque modification
document.querySelectorAll('.js-meal-input').forEach(input => {
input.addEventListener('change', async function() {
const fd = new FormData();
fd.append('action', 'save_cell');
fd.append('plan_date', this.dataset.d);
fd.append('service', this.dataset.s);
fd.append('person_id', this.dataset.p);
fd.append('meal_name', this.value);
try {
await pachaFetch('/modules/food/includes/api/save-meals.php', { method: 'POST', body: fd });
flashSuccess(this);
} catch(e) {
console.error("Save error", e);
}
});
});
});
// Focus Context Bar
let copyState = { type: null, sourceDate: null, service: null, personIdx: null, sourceElements: [], fullDayName: '' };
document.addEventListener('focusin', (e) => {
if(e.target.classList.contains('js-meal-input')) {
copyState = {
type: 'cell',
sourceDate: e.target.getAttribute('data-d'),
service: e.target.getAttribute('data-s'),
personIdx: e.target.getAttribute('data-p'),
sourceElements: [e.target],
fullDayName: ''
};
document.getElementById('contextToolbar').classList.add('active');
}
});
document.addEventListener('focusout', (e) => {
setTimeout(() => {
if(document.activeElement && !document.activeElement.classList.contains('js-meal-input')) {
document.getElementById('contextToolbar').classList.remove('active');
}
}, 150);
});
// Actions Toolbar
window.applyToAllInRow = async function() {
if(copyState.type !== 'cell' || !copyState.sourceElements[0].value.trim()) return;
const val = copyState.sourceElements[0].value;
const inputs = document.querySelectorAll(`.js-meal-input[data-d="${copyState.sourceDate}"][data-s="${copyState.service}"]`);
let updates = [];
inputs.forEach(inp => {
if(inp !== copyState.sourceElements[0]) {
inp.value = val;
flashSuccess(inp);
updates.push({ plan_date: inp.dataset.d, service: inp.dataset.s, person_id: inp.dataset.p, meal_name: val });
}
});
if (updates.length > 0) {
const fd = new FormData();
fd.append('action', 'save_bulk');
fd.append('updates', JSON.stringify(updates));
await pachaFetch('/modules/food/includes/api/save-meals.php', { method: 'POST', body: fd });
}
};
window.openCopySheetFromCell = function() {
if(!copyState.sourceElements[0] || !copyState.sourceElements[0].value.trim()) return;
if(document.activeElement) document.activeElement.blur();
const svcLabel = copyState.service === 'lunch' ? (window.I18N['meal_lunch'] || 'Midi') : (window.I18N['meal_dinner'] || 'Soir');
const dObj = new Date(copyState.sourceDate);
const dayKey = dObj.toLocaleDateString('en-US', {weekday: 'short'}).toLowerCase();
const dayName = window.I18N['day_full_' + dayKey] || dObj.toLocaleDateString();
document.getElementById('copySourceLabel').innerText = `Source : ${window.I18N['source_individual'] || 'Individuel'} (${dayName} ${svcLabel})`;
document.getElementById('copyModal').classList.add('open');
document.body.classList.add('no-scroll');
};
window.openCopySheetFromRow = function(dateStr, service, fullDayName) {
const inputs = document.querySelectorAll(`.js-meal-input[data-d="${dateStr}"][data-s="${service}"]`);
if (!Array.from(inputs).some(inp => inp.value.trim() !== '')) return;
copyState = { type: 'row', sourceDate: dateStr, service: service, sourceElements: Array.from(inputs), fullDayName: fullDayName };
const svcLabel = service === 'lunch' ? (window.I18N['meal_lunch'] || 'Midi') : (window.I18N['meal_dinner'] || 'Soir');
document.getElementById('copySourceLabel').innerText = `Source : ${window.I18N['source_row'] || 'Repas commun'} (${fullDayName} ${svcLabel})`;
document.getElementById('copyModal').classList.add('open');
document.body.classList.add('no-scroll');
};
window.closeCopyModal = function() {
document.getElementById('copyModal').classList.remove('open');
document.body.classList.remove('no-scroll');
};
window.executeCopy = async function(targetDate, targetService) {
let updates = [];
if(copyState.type === 'cell') {
const val = copyState.sourceElements[0].value;
const targetInput = document.querySelector(`.js-meal-input[data-d="${targetDate}"][data-s="${targetService}"][data-p="${copyState.personIdx}"]`);
if(targetInput) {
targetInput.value = val;
flashSuccess(targetInput);
updates.push({ plan_date: targetDate, service: targetService, person_id: copyState.personIdx, meal_name: val });
}
} else if (copyState.type === 'row') {
const targetInputs = document.querySelectorAll(`.js-meal-input[data-d="${targetDate}"][data-s="${targetService}"]`);
copyState.sourceElements.forEach((inp, idx) => {
if(inp.value.trim() !== '') {
targetInputs[idx].value = inp.value;
flashSuccess(targetInputs[idx]);
updates.push({ plan_date: targetDate, service: targetService, person_id: targetInputs[idx].dataset.p, meal_name: inp.value });
}
});
}
closeCopyModal();
if (updates.length > 0) {
const fd = new FormData();
fd.append('action', 'save_bulk');
fd.append('updates', JSON.stringify(updates));
await pachaFetch('/modules/meals/includes/api/save-meals.php', { method: 'POST', body: fd });
}
};
function flashSuccess(input) {
input.style.transition = 'background 0.3s';
input.style.background = 'var(--alert-success-bg)';
setTimeout(() => input.style.background = 'transparent', 400);
}
// NOUVEAU : Fonction Javascript d'Import PDF
window.importPdfMenu = async function(e) {
e.preventDefault();
const form = e.target;
const btn = document.getElementById('btnImportPdf');
const oldText = btn.innerText;
btn.disabled = true;
btn.innerText = '⏳...';
const fd = new FormData(form);
const actionUrl = form.getAttribute('action');
try {
const res = await pachaFetch(actionUrl, { method: 'POST', body: fd });
if (res && res.success) {
document.getElementById('importMenuModal').classList.remove('open');
document.body.classList.remove('no-scroll');
if (window.showToast) showToast(window.I18N['meal_import_success'] || 'Menus importés !', 'success');
setTimeout(() => window.location.reload(), 1000);
} else {
if (window.showToast) showToast(res.error || window.I18N['error_occured'], 'error');
else alert(res.error);
}
} catch(err) {
if (window.showToast) showToast(window.I18N['error_occured'], 'error');
} finally {
btn.disabled = false;
btn.innerText = oldText;
form.reset();
document.getElementById('pdfFileName').innerText = window.I18N['meal_drop_pdf'] || 'Cliquer pour ajouter le PDF';
}
};
</script>
+230 -9
View File
@@ -17,6 +17,24 @@ require_once dirname(__DIR__, 2) . '/includes/db.php';
$UPLOAD_DIR = '/uploads/garage/'; $UPLOAD_DIR = '/uploads/garage/';
if (!is_dir($UPLOAD_DIR)) { @mkdir($UPLOAD_DIR, 0755, true); } if (!is_dir($UPLOAD_DIR)) { @mkdir($UPLOAD_DIR, 0755, true); }
$pdo->exec("
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
");
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
@@ -38,6 +56,37 @@ function handleUpload(string $field, string $dir): ?string {
return null; return null;
} }
function handleGarageDocumentUpload(string $field, string $dir): ?array {
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
return null;
}
if ($_FILES[$field]['size'] > 12 * 1024 * 1024) {
gErr('Fichier trop volumineux (max 12 Mo).', 400);
}
$ext = strtolower(pathinfo($_FILES[$field]['name'], PATHINFO_EXTENSION));
$allowed = ['pdf','jpg','jpeg','png','gif','webp','doc','docx','xls','xlsx','odt','txt'];
if (!in_array($ext, $allowed, true)) {
gErr('Format non autorisé.', 400);
}
$fname = 'd_' . uniqid('', true) . '.' . $ext;
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) {
return null;
}
$mimeMap = [
'pdf' => 'application/pdf', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp',
'doc' => 'application/msword', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel', 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'odt' => 'application/vnd.oasis.opendocument.text', 'txt' => 'text/plain',
];
return [
'filename' => $fname,
'original_name' => $_FILES[$field]['name'],
'mime' => $mimeMap[$ext] ?? 'application/octet-stream',
'size' => (int) $_FILES[$field]['size'],
];
}
// ─── Vehicles ───────────────────────────────────────────────────────────────── // ─── Vehicles ─────────────────────────────────────────────────────────────────
if ($action === 'vehicles') { if ($action === 'vehicles') {
if ($method === 'GET') { if ($method === 'GET') {
@@ -47,21 +96,50 @@ if ($action === 'vehicles') {
$vehicle = $v->fetch(); if (!$vehicle) gErr('Véhicule introuvable', 404); $vehicle = $v->fetch(); if (!$vehicle) gErr('Véhicule introuvable', 404);
$s = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as total FROM pf_maintenances WHERE vehicle_id = ?"); $s->execute([$id]); $vehicle['stats'] = $s->fetch(); $s = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as total FROM pf_maintenances WHERE vehicle_id = ?"); $s->execute([$id]); $vehicle['stats'] = $s->fetch();
$p = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(price*quantity),0) as total FROM pf_parts WHERE vehicle_id = ?"); $p->execute([$id]); $vehicle['parts_stats'] = $p->fetch(); $p = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(price*quantity),0) as total FROM pf_parts WHERE vehicle_id = ?"); $p->execute([$id]); $vehicle['parts_stats'] = $p->fetch();
$dc = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NULL");
$dc->execute([$id]);
$vehicle['vehicle_docs_count'] = (int) $dc->fetchColumn();
$dm = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NOT NULL");
$dm->execute([$id]);
$vehicle['maint_docs_count'] = (int) $dm->fetchColumn();
gOk($vehicle); gOk($vehicle);
} }
$stmt = $pdo->query("SELECT v.*, (SELECT COUNT(*) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as maintenance_count, (SELECT COALESCE(SUM(cost),0) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as total_cost, (SELECT date FROM pf_maintenances m WHERE m.vehicle_id = v.id ORDER BY date DESC LIMIT 1) as last_maintenance FROM pf_vehicles v ORDER BY v.created_at DESC"); $stmt = $pdo->query("SELECT v.*, (SELECT COUNT(*) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as maintenance_count, (SELECT COALESCE(SUM(cost),0) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as total_cost, (SELECT date FROM pf_maintenances m WHERE m.vehicle_id = v.id ORDER BY date DESC LIMIT 1) as last_maintenance FROM pf_vehicles v ORDER BY v.created_at DESC");
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;
@@ -76,11 +154,110 @@ if ($action === 'vehicles') {
} }
if ($method === 'DELETE') { if ($method === 'DELETE') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
$docs = $pdo->prepare('SELECT filename FROM pf_garage_documents WHERE vehicle_id = ?');
$docs->execute([$id]);
foreach ($docs->fetchAll(PDO::FETCH_COLUMN) as $fn) {
if ($fn) {
@unlink($UPLOAD_DIR . basename($fn));
}
}
$v = $pdo->prepare("SELECT photo FROM pf_vehicles WHERE id=?"); $v->execute([$id]); $row = $v->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']); $v = $pdo->prepare("SELECT photo FROM pf_vehicles WHERE id=?"); $v->execute([$id]); $row = $v->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
$pdo->prepare("DELETE FROM pf_vehicles WHERE id = ?")->execute([$id]); gOk(['deleted' => true]); $pdo->prepare("DELETE FROM pf_vehicles WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
} }
} }
// ─── Documents (véhicule / entretien — factures, PDF, etc.) ───────────────────
if ($action === 'garage_documents') {
if ($method === 'GET') {
$vid = $_GET['vehicle_id'] ?? null;
$mid = $_GET['maintenance_id'] ?? null;
$vehicleOnly = ($_GET['vehicle_only'] ?? '') === '1';
if ($mid) {
$stmt = $pdo->prepare("SELECT d.*, v.name as vehicle_name FROM pf_garage_documents d JOIN pf_vehicles v ON v.id = d.vehicle_id WHERE d.maintenance_id = ? ORDER BY d.created_at DESC");
$stmt->execute([$mid]);
gOk($stmt->fetchAll());
}
if (!$vid) {
gErr('vehicle_id ou maintenance_id requis');
}
if ($vehicleOnly) {
$stmt = $pdo->prepare("SELECT * FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NULL ORDER BY created_at DESC");
$stmt->execute([$vid]);
} else {
$stmt = $pdo->prepare("SELECT * FROM pf_garage_documents WHERE vehicle_id = ? ORDER BY created_at DESC");
$stmt->execute([$vid]);
}
gOk($stmt->fetchAll());
}
if ($method === 'POST') {
$d = $_POST;
$vid = (int) ($d['vehicle_id'] ?? 0);
if ($vid < 1) {
gErr('vehicle_id manquant');
}
$mid = isset($d['maintenance_id']) && $d['maintenance_id'] !== '' ? (int) $d['maintenance_id'] : null;
if ($mid) {
$chk = $pdo->prepare("SELECT id FROM pf_maintenances WHERE id = ? AND vehicle_id = ?");
$chk->execute([$mid, $vid]);
if (!$chk->fetch()) {
gErr('Entretien invalide pour ce véhicule');
}
}
$up = handleGarageDocumentUpload('file', $UPLOAD_DIR);
if (!$up) {
gErr('Upload échoué ou fichier manquant');
}
$label = trim($d['label'] ?? '');
$pdo->prepare("INSERT INTO pf_garage_documents (vehicle_id, maintenance_id, label, filename, original_name, mime, size) VALUES (?,?,?,?,?,?,?)")
->execute([$vid, $mid, $label ?: null, $up['filename'], $up['original_name'], $up['mime'], $up['size']]);
gOk(['id' => (int) $pdo->lastInsertId()]);
}
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if ($id < 1) {
gErr('ID manquant');
}
$r = $pdo->prepare("SELECT filename FROM pf_garage_documents WHERE id = ?");
$r->execute([$id]);
$row = $r->fetch();
if (!$row) {
gErr('Document introuvable', 404);
}
if (!empty($row['filename'])) {
@unlink($UPLOAD_DIR . $row['filename']);
}
$pdo->prepare("DELETE FROM pf_garage_documents WHERE id = ?")->execute([$id]);
gOk(['deleted' => true]);
}
}
if ($action === 'garage_document') {
$id = (int) ($_GET['id'] ?? 0);
if ($id < 1) {
gErr('ID manquant');
}
$stmt = $pdo->prepare('SELECT * FROM pf_garage_documents WHERE id = ?');
$stmt->execute([$id]);
$doc = $stmt->fetch();
if (!$doc || empty($doc['filename'])) {
http_response_code(404);
exit;
}
$f = basename($doc['filename']);
$path = $UPLOAD_DIR . $f;
if (!preg_match('/^d_[a-zA-Z0-9._-]+$/', $f) || !is_file($path)) {
http_response_code(404);
exit;
}
$mime = $doc['mime'] ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
$disp = $doc['original_name'] ?: $f;
header('Content-Disposition: inline; filename="' . rawurlencode($disp) . '"');
header('Cache-Control: private, max-age=3600');
readfile($path);
exit;
}
// ─── Maintenances ───────────────────────────────────────────────────────────── // ─── Maintenances ─────────────────────────────────────────────────────────────
if ($action === 'maintenances') { if ($action === 'maintenances') {
$vid = $_GET['vehicle_id'] ?? null; $vid = $_GET['vehicle_id'] ?? null;
@@ -89,10 +266,13 @@ if ($action === 'maintenances') {
if ($id) { if ($id) {
$stmt = $pdo->prepare("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.id = ?"); $stmt = $pdo->prepare("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.id = ?");
$stmt->execute([$id]); $m = $stmt->fetch(); if (!$m) gErr('Entretien introuvable', 404); $stmt->execute([$id]); $m = $stmt->fetch(); if (!$m) gErr('Entretien introuvable', 404);
$dc = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE maintenance_id = ?");
$dc->execute([$id]);
$m['documents_count'] = (int) $dc->fetchColumn();
gOk($m); gOk($m);
} }
if ($vid) { if ($vid) {
$stmt = $pdo->prepare("SELECT m.*, GROUP_CONCAT(p.name SEPARATOR ', ') as parts_names, COUNT(p.id) as parts_count, COALESCE(SUM(p.price*p.quantity),0) as parts_cost FROM pf_maintenances m LEFT JOIN pf_parts p ON p.maintenance_id = m.id WHERE m.vehicle_id = ? GROUP BY m.id ORDER BY m.date DESC, m.created_at DESC"); $stmt = $pdo->prepare("SELECT m.*, GROUP_CONCAT(p.name SEPARATOR ', ') as parts_names, COUNT(p.id) as parts_count, COALESCE(SUM(p.price*p.quantity),0) as parts_cost, (SELECT COUNT(*) FROM pf_garage_documents d WHERE d.maintenance_id = m.id) as documents_count FROM pf_maintenances m LEFT JOIN pf_parts p ON p.maintenance_id = m.id WHERE m.vehicle_id = ? GROUP BY m.id ORDER BY m.date DESC, m.created_at DESC");
$stmt->execute([$vid]); gOk($stmt->fetchAll()); $stmt->execute([$vid]); gOk($stmt->fetchAll());
} }
$stmt = $pdo->query("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.next_date IS NOT NULL OR m.next_km IS NOT NULL ORDER BY m.next_date ASC"); $stmt = $pdo->query("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.next_date IS NOT NULL OR m.next_km IS NOT NULL ORDER BY m.next_date ASC");
@@ -102,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]);
@@ -126,6 +322,13 @@ if ($action === 'maintenances') {
} }
if ($method === 'DELETE') { if ($method === 'DELETE') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
$docs = $pdo->prepare('SELECT filename FROM pf_garage_documents WHERE maintenance_id = ?');
$docs->execute([$id]);
foreach ($docs->fetchAll(PDO::FETCH_COLUMN) as $fn) {
if ($fn) {
@unlink($UPLOAD_DIR . basename($fn));
}
}
$pdo->prepare("DELETE FROM pf_maintenances WHERE id = ?")->execute([$id]); gOk(['deleted' => true]); $pdo->prepare("DELETE FROM pf_maintenances WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
} }
} }
@@ -143,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') {
@@ -206,4 +427,4 @@ if ($action === 'upload_part_photo') {
$photo = handleUpload('photo', $UPLOAD_DIR); if (!$photo) gErr('Upload échoué'); $photo = handleUpload('photo', $UPLOAD_DIR); if (!$photo) gErr('Upload échoué');
$pdo->prepare("UPDATE pf_parts SET photo = ? WHERE id = ?")->execute([$photo, $id]); gOk(['photo' => $photo]); $pdo->prepare("UPDATE pf_parts SET photo = ? WHERE id = ?")->execute([$photo, $id]); gOk(['photo' => $photo]);
} }
gErr('Action inconnue', 404); gErr('Action inconnue', 404);
+163 -4
View File
@@ -146,7 +146,7 @@
padding: .65rem .9rem; border-bottom: 1px solid var(--border-light); white-space: nowrap; padding: .65rem .9rem; border-bottom: 1px solid var(--border-light); white-space: nowrap;
} }
.table-wrap table td { .table-wrap table td {
padding: .65rem .9rem; border-bottom: 1px solid #f1f5f9; padding: .65rem .9rem; border-bottom: 1px solid var(--border-light);
vertical-align: middle; color: var(--text-main); vertical-align: middle; color: var(--text-main);
} }
.table-wrap table tr:last-child td { border-bottom: none; } .table-wrap table tr:last-child td { border-bottom: none; }
@@ -268,7 +268,8 @@ textarea.form-control { resize: vertical; min-height: 80px; }
/* ─── TOASTS ─────────────────────────────────────────────────────────────────── */ /* ─── TOASTS ─────────────────────────────────────────────────────────────────── */
.toast-container { .toast-container {
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999;
display: flex; flex-direction: column; gap: .5rem; pointer-events: none; display: flex; flex-direction: column; align-items: flex-end;
gap: .5rem; pointer-events: none;
} }
.toast { .toast {
background: var(--bg-panel); border: 1px solid var(--border-light); background: var(--bg-panel); border: 1px solid var(--border-light);
@@ -292,7 +293,7 @@ textarea.form-control { resize: vertical; min-height: 80px; }
.scroll-list-item { .scroll-list-item {
display: flex; align-items: center; gap: .75rem; display: flex; align-items: center; gap: .75rem;
padding: .45rem .75rem; cursor: pointer; padding: .45rem .75rem; cursor: pointer;
border-bottom: 1px solid #f1f5f9; transition: background .12s; border-bottom: 1px solid var(--border-light); transition: background .12s;
user-select: none; user-select: none;
} }
.scroll-list-item:last-child { border-bottom: none; } .scroll-list-item:last-child { border-bottom: none; }
@@ -303,18 +304,169 @@ textarea.form-control { resize: vertical; min-height: 80px; }
.item-brand { font-size: .75rem; color: var(--text-muted); } .item-brand { font-size: .75rem; color: var(--text-muted); }
.item-price { font-size: .875rem; font-weight: 600; color: var(--primary); white-space: nowrap; } .item-price { font-size: .875rem; font-weight: 600; color: var(--primary); white-space: nowrap; }
/* ─── DASHBOARD LAYOUT ─────────────────────────────────────────────────────── */
.garage-dash-layout {
display: grid;
grid-template-columns: 1fr minmax(260px, 380px);
gap: 1.5rem;
align-items: start;
}
.garage-dash-main { min-width: 0; }
.garage-dash-aside { padding: 1rem 1.25rem; }
.garage-card-header { flex-wrap: wrap; align-items: center; gap: 0.75rem; }
.garage-tab-actions {
margin-bottom: 1rem;
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
}
/* ─── TABS (scroll mobile) ─────────────────────────────────────────────────── */
.garage-tabs {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
gap: 0.15rem;
padding-bottom: 2px;
}
.garage-tabs .tab-btn {
flex-shrink: 0;
white-space: nowrap;
padding: 0.55rem 0.75rem;
font-size: 0.82rem;
}
/* ─── VEHICLE HEADER ACTIONS ───────────────────────────────────────────────── */
.vehicle-detail-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-self: flex-start;
}
.garage-docs-recap {
margin-top: 0.75rem;
padding: 0.65rem 0.85rem;
background: #f8fafc;
border: 1px solid var(--border-light);
border-radius: 8px;
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.45;
}
.garage-docs-recap strong { color: var(--text-main); }
.btn-link-sm {
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
color: var(--primary);
font-size: inherit;
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-link-sm:hover { color: var(--primary-dark); }
.garage-maint-doc-badge { font-size: 0.65rem; vertical-align: middle; margin-left: 0.15rem; }
/* ─── DOCUMENTS ───────────────────────────────────────────────────────────── */
.garage-docs-card .card-header { padding: 1rem 1.25rem; }
.garage-doc-upload-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1rem;
margin-top: 0.75rem;
}
.garage-doc-upload-btn { align-self: flex-end; flex-shrink: 0; }
.garage-doc-hint {
font-size: 0.78rem;
color: var(--text-muted);
margin: 0.35rem 0 0.75rem;
}
.garage-doc-empty { padding: 1.25rem !important; }
.garage-doc-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border-light);
border-radius: 8px;
overflow: hidden;
}
.garage-doc-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--border-light);
background: #fff;
}
.garage-doc-item:last-child { border-bottom: none; }
.garage-doc-link {
flex: 1;
min-width: 0;
font-weight: 500;
color: var(--primary);
text-decoration: none;
word-break: break-word;
}
.garage-doc-link:hover { text-decoration: underline; }
.garage-doc-meta {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.garage-doc-item .btn-icon { flex-shrink: 0; margin-left: auto; }
/* ─── RESPONSIVE ─────────────────────────────────────────────────────────────── */ /* ─── RESPONSIVE ─────────────────────────────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.container { padding: 1rem; } .container { padding: 1rem; }
.vehicle-detail-header { flex-direction: column; } .vehicle-detail-header { flex-direction: column; }
.vehicle-detail-img { width: 100%; height: 200px; } .vehicle-detail-img { width: 100%; height: 200px; }
.vehicle-detail-actions {
width: 100%;
flex-direction: column;
align-self: stretch;
}
.vehicle-detail-actions .btn { width: 100%; justify-content: center; }
.form-row, .form-row-3 { grid-template-columns: 1fr; } .form-row, .form-row-3 { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: repeat(2, 1fr); } .stats-grid { grid-template-columns: repeat(2, 1fr); }
.modal { max-height: 95vh; } .modal { max-height: 95vh; }
.vehicles-grid { grid-template-columns: 1fr; } .vehicles-grid { grid-template-columns: 1fr; }
.meta-grid { gap: .75rem; } .meta-grid { gap: 0.75rem; }
.garage-subnav { padding: 8px 12px; overflow-x: auto; flex-wrap: nowrap; } .garage-subnav { padding: 8px 12px; overflow-x: auto; flex-wrap: nowrap; }
.garage-subnav .nav-link { white-space: nowrap; } .garage-subnav .nav-link { white-space: nowrap; }
.garage-dash-layout { grid-template-columns: 1fr; }
.garage-dash-aside { order: -1; }
.garage-doc-upload-bar { flex-direction: column; align-items: stretch; }
.garage-doc-upload-btn { width: 100%; align-self: stretch; }
.garage-doc-item { flex-direction: column; align-items: stretch; }
.garage-doc-item .btn-icon { margin-left: 0; align-self: flex-end; }
.garage-doc-meta { white-space: normal; }
.toast-container {
left: 0.75rem;
right: 0.75rem;
bottom: max(1rem, env(safe-area-inset-bottom, 0px));
align-items: stretch;
}
.toast { max-width: none; min-width: 0; width: 100%; }
}
@media (max-width: 640px) {
.table-wrap {
margin-left: -0.25rem;
margin-right: -0.25rem;
border-radius: 6px;
}
.table-wrap table { min-width: 640px; }
#inline-part-form .form-row,
#inline-part-form .form-row-3 { grid-template-columns: 1fr !important; }
} }
/* ══ DARK MODE — Garage module ══════════════════════════════════ */ /* ══ DARK MODE — Garage module ══════════════════════════════════ */
@@ -364,3 +516,10 @@ textarea.form-control { resize: vertical; min-height: 80px; }
[data-theme="dark"] .scroll-list-item:hover { background: rgba(255,255,255,.04); } [data-theme="dark"] .scroll-list-item:hover { background: rgba(255,255,255,.04); }
[data-theme="dark"] .item-name { color: var(--text-main); } [data-theme="dark"] .item-name { color: var(--text-main); }
[data-theme="dark"] #inline-part-form { background: #1c2128 !important; border-color: var(--border-light) !important; } [data-theme="dark"] #inline-part-form { background: #1c2128 !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .garage-docs-recap { background: rgba(255,255,255,.04); border-color: var(--border-light); color: var(--text-muted); }
[data-theme="dark"] .garage-docs-recap strong { color: var(--text-main); }
[data-theme="dark"] .btn-link-sm { color: var(--primary); }
[data-theme="dark"] .garage-doc-list { border-color: var(--border-light); }
[data-theme="dark"] .garage-doc-item { background: #1c2128; border-color: rgba(48,54,61,.5); }
[data-theme="dark"] .garage-doc-link { color: #93c5fd; }
[data-theme="dark"] .garage-doc-meta { color: var(--text-muted); }
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;
}
+297 -147
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,16 +16,13 @@
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;
background: white;
padding: 4px; padding: 4px;
border-radius: 8px; border-radius: 8px;
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;
@@ -36,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: #334155; background: var(--text-main);
color: white; color: var(--bg-panel);
} }
/* === FILTRES STICKY === */ /* === FILTRES === */
.pf-filter-bar { .pf-filter-bar {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -53,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;
@@ -63,40 +57,10 @@
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;
background: white; background: var(--bg-panel);
padding: 20px; padding: 20px;
border-radius: 16px; border-radius: 16px;
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
@@ -121,21 +85,21 @@
overflow-x: auto; overflow-x: auto;
padding-bottom: 12px; padding-bottom: 12px;
margin-bottom: 15px; margin-bottom: 15px;
border-bottom: 1px solid #f1f5f9; 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;
background: #f8fafc; background: var(--bg-page);
padding: 4px 12px; padding: 4px 12px;
border-radius: 50px; border-radius: 50px;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
color: #475569; color: var(--text-muted);
border: 1px solid #cbd5e1; border: 1px solid var(--border-strong);
transition: opacity 0.2s; transition: opacity 0.2s;
} }
@@ -146,9 +110,9 @@
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
} }
.pf-gift-card-compact { .pf-gift-card-compact {
background: white; background: var(--bg-panel);
border-radius: 10px; border-radius: 10px;
border: 1px solid #e2e8f0; border: 1px solid var(--border-light);
padding: 12px; padding: 12px;
position: relative; position: relative;
transition: 0.2s; transition: 0.2s;
@@ -157,12 +121,12 @@
min-height: 100px; min-height: 100px;
} }
.pf-gift-card-compact:hover { .pf-gift-card-compact:hover {
border-color: #cbd5e1; border-color: var(--border-strong);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.pf-gift-title { .pf-gift-title {
font-weight: 600; font-weight: 600;
color: #1e293b; color: var(--text-main);
font-size: 0.95rem; font-size: 0.95rem;
margin: 0 0 10px 0; margin: 0 0 10px 0;
line-height: 1.3; line-height: 1.3;
@@ -183,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;
@@ -197,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;
@@ -224,7 +174,7 @@
margin: 0; margin: 0;
} }
.pf-tricount-item { .pf-tricount-item {
background: white; background: var(--bg-panel);
padding: 12px 15px; padding: 12px 15px;
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: 8px; border-radius: 8px;
@@ -242,19 +192,158 @@
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;
}
/* === COMPOSANT MULTI-SELECT VANILLA === */
.pf-multi-select {
position: relative;
display: inline-block;
}
.pf-ms-trigger {
padding: 6px 16px;
border-radius: 20px;
border: 1px solid var(--border-light);
backdrop-filter: blur(8px);
font-size: 0.85rem;
font-weight: 600;
color: var(--text-main);
cursor: pointer;
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
gap: 6px;
user-select: none;
}
.pf-ms-trigger:hover {
border-color: #cbd5e1;
}
.pf-ms-trigger.active {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.pf-ms-dropdown {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 0;
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 8px;
box-shadow: var(--shadow-lg);
z-index: 100;
min-width: 200px;
flex-direction: column;
gap: 4px;
max-height: 60vh;
overflow-y: auto;
}
.pf-ms-dropdown.open {
display: flex;
animation: popDown 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.pf-ms-option {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
padding: 8px 10px;
border-radius: 6px;
color: var(--text-main);
transition: 0.2s;
}
.pf-ms-option:hover {
background: var(--bg-page);
}
.pf-ms-option input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--primary);
width: 16px;
height: 16px;
}
.pf-ms-option.is-all {
font-weight: 700;
border-bottom: 1px solid var(--border-light);
padding-bottom: 10px;
margin-bottom: 4px;
border-radius: 6px 6px 0 0;
}
@keyframes popDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -282,98 +371,159 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* === COMPOSANT MULTI-SELECT VANILLA === */
.pf-multi-select { /* === MODALE EDITION CADEAU === */
position: relative; .gift-modal-custom-content {
display: inline-block; 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);
} }
/* Le bouton déclencheur (réutilise le style des filtres) */ .gl-pane {
.pf-ms-trigger { display: none;
padding: 6px 16px; animation: fadeIn 0.3s ease;
border-radius: 20px; }
border: 1px solid var(--border-light); .gl-pane.active {
background: rgba(255, 255, 255, 0.95); display: block;
backdrop-filter: blur(8px); }
font-size: 0.85rem; .gl-pane-title {
font-weight: 600; margin-top: 0;
color: var(--text-main); 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; cursor: pointer;
box-shadow: var(--shadow-sm); width: 38px;
height: 38px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; 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; user-select: none;
} }
.pf-ms-trigger:hover { .emoji-option:hover {
background: var(--bg-page);
border-color: #cbd5e1; border-color: #cbd5e1;
} }
.pf-ms-trigger.active { .emoji-option.selected {
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); 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);
} }
/* Le menu déroulant */ /* Alignement des boutons d'ajout */
.pf-ms-dropdown { .modal-actions-right {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 0;
background: white;
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 8px;
box-shadow: var(--shadow-lg);
z-index: 100;
min-width: 200px;
flex-direction: column;
gap: 4px;
max-height: 60vh;
overflow-y: auto;
}
.pf-ms-dropdown.open {
display: flex; display: flex;
animation: popDown 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; gap: 10px;
justify-content: flex-end;
width: 100%;
margin-top: 10px;
padding-top: 15px;
border-top: 1px solid var(--border-light);
} }
/* Les options (checkboxes) */ /* Édition In-Place sur les lignes de la liste */
.pf-ms-option { .inline-edit-form {
display: flex; display: flex;
align-items: center;
gap: 8px; gap: 8px;
font-size: 0.85rem; align-items: center;
font-weight: 500; background: var(--bg-subtle);
cursor: pointer; padding: 8px 12px;
padding: 8px 10px; border-radius: 8px;
border-radius: 6px; width: 100%;
color: var(--text-main); flex-wrap: wrap; /* Évite que ça dépasse sur mobile */
transition: 0.2s;
} }
.pf-ms-option:hover { .inline-edit-form .pf-input {
background: #f8fafc;
}
.pf-ms-option input[type="checkbox"] {
margin: 0; margin: 0;
cursor: pointer;
accent-color: var(--primary);
width: 16px;
height: 16px;
}
.pf-ms-option.is-all {
font-weight: 700;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 10px;
margin-bottom: 4px;
border-radius: 6px 6px 0 0;
}
@keyframes popDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
+260
View File
@@ -0,0 +1,260 @@
<?php
ob_start();
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
set_exception_handler(function (\Throwable $e) {
if (!headers_sent()) {
header('Content-Type: application/json');
http_response_code(500);
}
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
exit;
});
require_once dirname(__DIR__, 2) . '/includes/db.php';
require_once dirname(__DIR__, 2) . '/includes/i18n.php';
$pdo->exec("
CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY,
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
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
$pdo->exec("
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
");
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
function gOk($d)
{
echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE);
exit;
}
function gErr($m, $c = 400)
{
http_response_code($c);
echo json_encode(['ok' => false, 'error' => $m]);
exit;
}
function gBody()
{
return json_decode(file_get_contents('php://input'), true) ?? [];
}
function groceryNormalize(string $label): string
{
$t = trim($label);
if ($t === '') {
return '';
}
if (function_exists('mb_strtolower')) {
$t = mb_strtolower($t, 'UTF-8');
} else {
$t = strtolower($t);
}
if (function_exists('mb_strlen') && mb_strlen($t, 'UTF-8') > 500) {
$t = mb_substr($t, 0, 500, 'UTF-8');
} elseif (strlen($t) > 500) {
$t = substr($t, 0, 500);
}
return $t;
}
function groceryHistoryMax(PDO $pdo): int
{
try {
$s = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'");
$v = $s ? $s->fetchColumn() : false;
$n = ($v !== false && $v !== null && $v !== '') ? (int) $v : 20;
} catch (\Throwable $e) {
$n = 20;
}
return max(1, min(50, $n));
}
function groceryTouchHistory(PDO $pdo, string $labelDisplay): void
{
$norm = groceryNormalize($labelDisplay);
if ($norm === '') {
return;
}
$disp = trim($labelDisplay);
if (function_exists('mb_strlen') && mb_strlen($disp, 'UTF-8') > 500) {
$disp = mb_substr($disp, 0, 500, 'UTF-8');
} elseif (strlen($disp) > 500) {
$disp = substr($disp, 0, 500);
}
$hash = hash('sha256', $norm, false);
$pdo->prepare(
'INSERT INTO pf_grocery_history (label_hash, label_display) VALUES (?,?)
ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()'
)->execute([$hash, $disp]);
$max = groceryHistoryMax($pdo);
$cnt = (int) $pdo->query('SELECT COUNT(*) FROM pf_grocery_history')->fetchColumn();
if ($cnt <= $max) {
return;
}
$toDel = $cnt - $max;
$stmt = $pdo->prepare('SELECT id FROM pf_grocery_history ORDER BY last_used_at ASC, id ASC LIMIT ?');
$stmt->execute([$toDel]);
$ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!$ids) {
return;
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$pdo->prepare("DELETE FROM pf_grocery_history WHERE id IN ($placeholders)")->execute($ids);
}
function groceryCurrentNormSet(PDO $pdo): array
{
$rows = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN);
$set = [];
foreach ($rows as $lab) {
$n = groceryNormalize((string) $lab);
if ($n !== '') {
$set[$n] = true;
}
}
return $set;
}
if ($action === 'items') {
if ($method === 'GET') {
$rows = $pdo->query(
"SELECT id, label, in_cart, position, created_at, updated_at
FROM pf_grocery_items
ORDER BY in_cart ASC, position ASC, id ASC"
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$r) {
$r['in_cart'] = (int) $r['in_cart'];
$r['id'] = (int) $r['id'];
$r['position'] = (int) $r['position'];
}
unset($r);
gOk($rows);
}
if ($method === 'POST') {
$d = gBody();
$label = trim($d['label'] ?? '');
if ($label === '') {
gErr('Libellé requis');
}
$norm = groceryNormalize($label);
$chk = $pdo->query('SELECT label FROM pf_grocery_items');
while ($existing = $chk->fetchColumn()) {
if (groceryNormalize((string) $existing) === $norm) {
gErr(function_exists('tr') ? tr('groceries_err_duplicate') : 'Ce produit est déjà dans la liste.');
}
}
$max = (int) $pdo->query('SELECT COALESCE(MAX(position),0) FROM pf_grocery_items')->fetchColumn();
$pdo->prepare('INSERT INTO pf_grocery_items (label, position) VALUES (?,?)')->execute([$label, $max + 1]);
$id = (int) $pdo->lastInsertId();
groceryTouchHistory($pdo, $label);
$s = $pdo->prepare('SELECT id, label, in_cart, position, created_at, updated_at FROM pf_grocery_items WHERE id=?');
$s->execute([$id]);
$row = $s->fetch(PDO::FETCH_ASSOC);
$row['in_cart'] = (int) $row['in_cart'];
$row['id'] = (int) $row['id'];
$row['position'] = (int) $row['position'];
gOk($row);
}
if ($method === 'PUT') {
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($id < 1) {
gErr('ID manquant');
}
$d = gBody();
if (array_key_exists('in_cart', $d)) {
$in = !empty($d['in_cart']) ? 1 : 0;
$pdo->prepare('UPDATE pf_grocery_items SET in_cart=?, updated_at=NOW() WHERE id=?')->execute([$in, $id]);
gOk(['in_cart' => $in]);
}
$label = trim($d['label'] ?? '');
if ($label === '') {
gErr('Libellé requis');
}
$pdo->prepare('UPDATE pf_grocery_items SET label=?, updated_at=NOW() WHERE id=?')->execute([$label, $id]);
groceryTouchHistory($pdo, $label);
gOk(['updated' => true]);
}
if ($method === 'DELETE') {
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($id < 1) {
gErr('ID manquant');
}
$pdo->prepare('DELETE FROM pf_grocery_items WHERE id=?')->execute([$id]);
gOk(['deleted' => true]);
}
}
if ($action === 'history' && $method === 'GET') {
$max = groceryHistoryMax($pdo);
$rows = $pdo->query(
'SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT 100'
)->fetchAll(PDO::FETCH_COLUMN);
$onList = groceryCurrentNormSet($pdo);
$labels = [];
foreach ($rows as $disp) {
$disp = (string) $disp;
if (groceryNormalize($disp) === '') {
continue;
}
if (isset($onList[groceryNormalize($disp)])) {
continue;
}
$labels[] = $disp;
if (count($labels) >= $max) {
break;
}
}
gOk(['labels' => $labels, 'history_max' => $max]);
}
if ($action === 'uncheck_all' && $method === 'POST') {
$pdo->exec('UPDATE pf_grocery_items SET in_cart=0');
gOk(['updated' => true]);
}
if ($action === 'delete_picked' && $method === 'POST') {
$labs = $pdo->query('SELECT label FROM pf_grocery_items WHERE in_cart=1')->fetchAll(PDO::FETCH_COLUMN);
foreach ($labs as $lab) {
groceryTouchHistory($pdo, (string) $lab);
}
$pdo->exec('DELETE FROM pf_grocery_items WHERE in_cart=1');
gOk(['deleted' => true]);
}
if ($action === 'clear_all' && $method === 'POST') {
$labs = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN);
foreach ($labs as $lab) {
groceryTouchHistory($pdo, (string) $lab);
}
$pdo->exec('DELETE FROM pf_grocery_items');
gOk(['deleted' => true]);
}
gErr('Action inconnue', 404);
+348
View File
@@ -0,0 +1,348 @@
/* HouseHub — Courses (liste de courses) */
.groceries-layout {
max-width: 600px;
margin: 0 auto;
padding: 0 1rem 2.5rem;
}
.groceries-hero {
padding: 1.25rem 0 1rem;
}
.groceries-hero h1 {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 0.35rem;
color: var(--text-main);
}
.groceries-hero p {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted);
}
/* Encart ajout — plus visible, aligné sur les cartes HouseHub */
.groceries-add-card {
margin-bottom: 1.35rem;
border-radius: 14px;
border: 1px solid var(--border-light);
background: var(--bg-panel);
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.groceries-add-card-inner {
padding: 1.15rem 1.25rem 1.25rem;
}
.groceries-quick-add {
display: flex;
align-items: stretch;
gap: 0.75rem;
}
.groceries-quick-add .groceries-add-input {
flex: 1;
min-width: 0;
padding: 14px 16px;
font-size: 1.05rem;
border-radius: 10px;
line-height: 1.35;
}
.groceries-add-submit {
flex-shrink: 0;
padding: 14px 22px !important;
font-size: 1rem !important;
border-radius: 10px !important;
white-space: nowrap;
}
@media (max-width: 520px) {
.groceries-quick-add {
flex-direction: column;
}
.groceries-add-submit {
width: 100%;
justify-content: center;
}
}
/* Actions secondaires — même famille que pf-btn du reste du site */
.groceries-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 1.15rem;
}
.groceries-toolbar-btn {
padding: 10px 18px !important;
font-size: 0.875rem !important;
font-weight: 600 !important;
border-radius: 10px !important;
}
.groceries-toolbar-btn--danger {
color: var(--danger) !important;
border-color: rgba(239, 68, 68, 0.35) !important;
background: var(--bg-panel) !important;
}
.groceries-toolbar-btn--danger:hover {
background: rgba(239, 68, 68, 0.08) !important;
border-color: rgba(239, 68, 68, 0.5) !important;
color: var(--danger) !important;
}
.groceries-toolbar-btn--outline {
border: 1px dashed var(--border-strong) !important;
background: transparent !important;
color: var(--text-muted) !important;
}
.groceries-toolbar-btn--outline:hover {
background: var(--bg-page) !important;
color: var(--text-main) !important;
border-color: var(--text-muted) !important;
}
.groceries-section-title {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin: 1rem 0 0.5rem;
}
.groceries-section-title:first-of-type {
margin-top: 0;
}
.groceries-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.groceries-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 12px;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.groceries-row:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.groceries-row.in-cart {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.08) 0%, var(--bg-panel) 48%);
border-color: rgba(34, 197, 94, 0.28);
}
.groceries-row.in-cart:hover {
border-color: rgba(34, 197, 94, 0.45);
}
.groceries-row.in-cart .groceries-label {
text-decoration: line-through;
color: var(--text-muted);
}
.groceries-check {
width: 2.5rem;
height: 2.5rem;
border-radius: 10px;
border: 2px solid var(--border-light);
background: var(--bg-page);
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: 700;
color: transparent;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s, transform 0.12s;
}
.groceries-check:hover {
border-color: var(--primary);
background: rgba(59, 130, 246, 0.08);
}
.groceries-check:active {
transform: scale(0.96);
}
/* Produit validé / dans le caddie — vert franc */
.groceries-check.checked {
background: linear-gradient(145deg, #22c55e 0%, #16a34a 100%);
border-color: #15803d;
color: #fff;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.28), 0 2px 6px rgba(22, 163, 74, 0.35);
}
.groceries-check.checked:hover {
background: linear-gradient(145deg, #4ade80 0%, #22c55e 100%);
border-color: #166534;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.35), 0 3px 10px rgba(22, 163, 74, 0.4);
}
.groceries-check.checked::after {
content: '✓';
}
.groceries-label {
flex: 1;
min-width: 0;
font-size: 0.95rem;
font-weight: 500;
color: var(--text-main);
line-height: 1.35;
word-break: break-word;
}
.groceries-row-actions {
display: flex;
gap: 0.15rem;
flex-shrink: 0;
align-items: center;
}
.groceries-empty {
text-align: center;
padding: 3rem 1.5rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.groceries-empty .groceries-empty-icon {
font-size: 2.75rem;
opacity: 0.35;
margin-bottom: 0.75rem;
}
.groceries-toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.groceries-toast {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 0.7rem 1rem;
font-size: 0.875rem;
min-width: 200px;
color: var(--text-main);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
border-left: 4px solid var(--primary);
}
.groceries-toast.error {
border-left-color: var(--danger);
}
[data-theme='dark'] .groceries-add-card {
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.25);
}
[data-theme='dark'] .groceries-row {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme='dark'] .groceries-row:hover {
border-color: #484f58;
}
[data-theme='dark'] .groceries-row.in-cart {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.12) 0%, var(--bg-panel) 50%);
border-color: rgba(34, 197, 94, 0.35);
}
[data-theme='dark'] .groceries-check {
border-color: #484f58;
background: var(--bg-page);
}
[data-theme='dark'] .groceries-check:hover {
background: rgba(59, 130, 246, 0.12);
border-color: var(--primary);
}
[data-theme='dark'] .groceries-check.checked {
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.22), 0 2px 8px rgba(0, 0, 0, 0.35);
}
/* Historique produits */
.groceries-history-card {
margin-top: 1.75rem;
padding: 1.15rem 1.2rem 1.2rem;
border-radius: 14px;
border: 1px solid var(--border-light);
background: var(--bg-panel);
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.05);
}
.groceries-history-h2 {
font-size: 0.95rem;
font-weight: 700;
margin: 0 0 0.35rem;
color: var(--text-main);
}
.groceries-history-meta {
font-size: 0.78rem;
color: var(--text-muted);
margin: 0 0 0.85rem;
line-height: 1.45;
}
.groceries-history-settings-link {
color: var(--primary);
font-weight: 600;
text-decoration: none;
}
.groceries-history-settings-link:hover {
text-decoration: underline;
}
.groceries-history-chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.groceries-history-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.75rem 0.45rem 0.55rem;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
color: var(--text-main);
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 999px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
text-align: left;
max-width: 100%;
}
.groceries-history-chip:hover {
border-color: var(--primary);
background: rgba(59, 130, 246, 0.06);
box-shadow: 0 1px 4px rgba(59, 130, 246, 0.12);
}
.groceries-history-chip-plus {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--primary);
color: var(--text-on-primary, #fff);
font-size: 0.85rem;
font-weight: 700;
flex-shrink: 0;
}
.groceries-history-empty {
margin: 0.5rem 0 0;
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.4;
}
[data-theme='dark'] .groceries-history-card {
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
}
[data-theme='dark'] .groceries-history-chip {
background: var(--bg-page);
border-color: var(--border-light);
}
[data-theme='dark'] .groceries-history-chip:hover {
background: rgba(59, 130, 246, 0.1);
}
+330
View File
@@ -0,0 +1,330 @@
// HouseHub — Liste de courses
const API = '/modules/groceries/api.php';
function escHtml(s) {
const d = document.createElement('div');
d.textContent = String(s ?? '');
return d.innerHTML;
}
function T(key, fallback) {
if (typeof tr === 'function') {
const v = tr(key);
if (v && v !== key) return v;
}
return fallback;
}
function escAttr(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
function toast(msg, type = 'success') {
const c = document.getElementById('groceries-toasts');
const t = document.createElement('div');
t.className = 'groceries-toast' + (type === 'error' ? ' error' : '');
t.textContent = msg;
c.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
async function api(action, method = 'GET', data = null, extra = '') {
const opts = {
method,
credentials: 'same-origin',
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
};
if (data) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(data);
}
const r = await fetch(API + '?action=' + action + extra, opts);
const text = await r.text();
let j;
try {
j = JSON.parse(text);
} catch (e) {
console.error('Bad JSON from API:', text.slice(0, 200));
throw new Error(T('error_occured', 'Erreur serveur'));
}
if (!j.ok) throw new Error(j.error || T('error_occured', 'Erreur'));
return j.data;
}
async function loadAll() {
try {
const [items, hist] = await Promise.all([api('items', 'GET'), api('history', 'GET')]);
render(items);
updateSubtitle(items);
renderHistory(hist);
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
function updateSubtitle(items) {
const el = document.getElementById('groceries-subtitle');
if (!el) return;
const pending = items.filter((i) => !parseInt(i.in_cart, 10)).length;
const cart = items.filter((i) => parseInt(i.in_cart, 10)).length;
if (!items.length) {
el.textContent = '';
return;
}
const tpl = typeof tr === 'function' ? tr('groceries_subtitle') : '';
el.textContent = (tpl && tpl !== 'groceries_subtitle' ? tpl : '{pending} à prendre · {cart} dans le caddie')
.replace('{pending}', String(pending))
.replace('{cart}', String(cart));
}
function renderHistory(hist) {
const root = document.getElementById('groceries-history-root');
if (!root) return;
const max = hist && typeof hist.history_max === 'number' ? hist.history_max : 20;
const labels = (hist && hist.labels) || [];
const title = escHtml(T('groceries_history_title', 'Historique'));
const metaTpl = T('groceries_history_meta', 'Jusqu’à {max} produits · ');
const meta = escHtml(metaTpl.replace('{max}', String(max)));
const linkLabel = escHtml(T('groceries_settings_link', 'Paramètres'));
let chips = '';
labels.forEach((lab) => {
const l = String(lab);
chips +=
'<button type="button" class="groceries-history-chip" data-label="' +
escAttr(l) +
'"><span class="groceries-history-chip-plus">+</span>' +
escHtml(l) +
'</button>';
});
const emptyHint = labels.length
? ''
: '<p class="groceries-history-empty">' + escHtml(T('groceries_history_empty', '')) + '</p>';
root.innerHTML =
'<div class="groceries-history-inner">' +
'<div class="groceries-history-head">' +
'<h2 class="groceries-history-h2">' +
title +
'</h2>' +
'<p class="groceries-history-meta">' +
meta +
'<a href="/settings.php" class="groceries-history-settings-link">' +
linkLabel +
'</a></p></div>' +
'<div class="groceries-history-chips">' +
chips +
'</div>' +
emptyHint +
'</div>';
}
function render(items) {
const wrap = document.getElementById('groceries-lists');
if (!items.length) {
const msg = T('groceries_empty', 'Rien pour le moment. Ajoutez un produit ci-dessus.');
wrap.innerHTML =
'<div class="groceries-empty"><div class="groceries-empty-icon">🛒</div><p>' + escHtml(msg) + '</p></div>';
return;
}
const pending = items.filter((i) => !parseInt(i.in_cart, 10));
const cart = items.filter((i) => parseInt(i.in_cart, 10));
const labPending = escHtml(T('groceries_section_pending', 'À prendre'));
const labCart = escHtml(T('groceries_section_cart', 'Dans le caddie'));
let html = '';
if (pending.length) {
html += '<div class="groceries-section-title">' + labPending + '</div><div class="groceries-list">';
pending.forEach((i) => {
html += rowHtml(i, false);
});
html += '</div>';
}
if (cart.length) {
html += '<div class="groceries-section-title">' + labCart + '</div><div class="groceries-list">';
cart.forEach((i) => {
html += rowHtml(i, true);
});
html += '</div>';
}
wrap.innerHTML = html;
}
function rowHtml(i, inCart) {
const id = i.id;
const checkClass = 'groceries-check' + (inCart ? ' checked' : '');
const rowClass = 'groceries-row' + (inCart ? ' in-cart' : '');
const aria = escAttr(T('groceries_aria_toggle', 'Marquer dans le caddie'));
return (
'<div class="' +
rowClass +
'" data-id="' +
id +
'">' +
'<button type="button" class="' +
checkClass +
'" onclick="toggleCart(' +
id +
',' +
(inCart ? '0' : '1') +
')" aria-label="' +
aria +
'"></button>' +
'<div class="groceries-label">' +
escHtml(i.label) +
'</div>' +
'<div class="groceries-row-actions">' +
'<button type="button" class="btn-icon-action edit" onclick="editItem(' +
id +
')" title="' +
escAttr(T('edit', 'Modifier')) +
'" aria-label="' +
escAttr(T('edit', 'Modifier')) +
'">✏️</button>' +
'<button type="button" class="btn-icon-action delete" onclick="deleteItem(' +
id +
')" title="' +
escAttr(T('delete', 'Supprimer')) +
'" aria-label="' +
escAttr(T('delete', 'Supprimer')) +
'">🗑️</button>' +
'</div></div>'
);
}
async function addFromInput() {
const input = document.getElementById('groceries-new');
const label = (input.value || '').trim();
if (!label) return;
try {
await api('items', 'POST', { label: label });
input.value = '';
input.focus();
toast(T('groceries_added', 'Ajouté'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function addFromHistory(label) {
const t = String(label || '').trim();
if (!t) return;
try {
await api('items', 'POST', { label: t });
toast(T('groceries_added', 'Ajouté'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function toggleCart(id, next) {
try {
await api('items', 'PUT', { in_cart: !!parseInt(next, 10) }, '&id=' + id);
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function editItem(id) {
const row = document.querySelector('.groceries-row[data-id="' + id + '"]');
const current = row ? row.querySelector('.groceries-label').textContent : '';
const label = window.prompt(T('groceries_prompt_edit', 'Modifier le produit'), current);
if (label === null) return;
const t = label.trim();
if (!t) {
toast(T('groceries_label_empty', 'Libellé vide'), 'error');
return;
}
try {
await api('items', 'PUT', { label: t }, '&id=' + id);
toast(T('groceries_updated', 'Mis à jour'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function deleteItem(id) {
const ok = await pachaConfirm(
T('groceries_confirm_del_title', 'Supprimer ?'),
T('groceries_confirm_del_body', 'Retirer ce produit de la liste ?')
);
if (!ok) return;
try {
await api('items', 'DELETE', null, '&id=' + id);
toast(T('groceries_deleted', 'Supprimé'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function uncheckAll() {
const ok = await pachaConfirm(
T('groceries_confirm_uncheck_title', 'Tout remettre à prendre'),
T('groceries_confirm_uncheck_body', '')
);
if (!ok) return;
try {
await api('uncheck_all', 'POST', {});
toast(T('groceries_reset_next', 'Liste prête pour la prochaine sortie'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function deletePicked() {
const ok = await pachaConfirm(
T('groceries_confirm_clear_title', 'Retirer les produits du caddie'),
T('groceries_confirm_clear_body', '')
);
if (!ok) return;
try {
await api('delete_picked', 'POST', {});
toast(T('groceries_picked_removed', 'Articles retirés'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function clearWholeList() {
const ok = await pachaConfirm(
T('groceries_confirm_empty_list_title', 'Vider toute la liste ?'),
T('groceries_confirm_empty_list_body', '')
);
if (!ok) return;
try {
await api('clear_all', 'POST', {});
toast(T('groceries_list_cleared', 'Liste vidée'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('groceries-new');
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addFromInput();
}
});
}
const histRoot = document.getElementById('groceries-history-root');
if (histRoot) {
histRoot.addEventListener('click', (e) => {
const btn = e.target.closest('.groceries-history-chip');
if (!btn || !histRoot.contains(btn)) return;
const raw = btn.getAttribute('data-label');
if (raw) addFromHistory(raw);
});
}
loadAll();
});
File diff suppressed because it is too large. Load diff
+1683 -368
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]);
+135 -32
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();
if ($_POST['action'] === 'update_item_datetime') { try {
$itemDate = !empty($_POST['item_date']) ? $_POST['item_date'] : null; if ($_POST['action'] === 'update_holiday_note') {
$itemTime = !empty($_POST['item_time']) ? $_POST['item_time'] : null; $hId = (int)$_POST['holiday_id'];
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET item_date = ?, item_time = ? WHERE id = ?"); $notes = $_POST['notes'] ?? '';
$stmt->execute([$itemDate, $itemTime, $itemId]); $stmt = $pdo->prepare("UPDATE pf_holidays SET notes = ? WHERE id = ?");
} else { $stmt->execute([$notes, $hId]);
$duration = (int)$_POST['duration']; echo json_encode(['success' => true]);
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET duration = ? WHERE id = ?"); exit;
$stmt->execute([$duration, $itemId]); }
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') {
$itemId = (int)$_POST['item_id'];
$itemDate = !empty($_POST['item_date']) ? $_POST['item_date'] : 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->execute([$itemDate, $itemTime, $itemId]);
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'];
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET duration = ? WHERE id = ?");
$stmt->execute([$duration, $itemId]);
echo json_encode(['success' => true]);
exit;
}
} catch (Exception $e) {
if ($pdo->inTransaction()) { $pdo->rollBack(); }
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
} }
echo json_encode(['success' => true]);
exit; // Crucial : on arrête le script ici !
} }
// --------------------------------===========================================
// 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
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?"); $insert_after = $_POST['insert_after'] ?? 'end';
$stmtMax->execute([$holiday_id]);
$max = $stmtMax->fetchColumn(); if ($insert_after === 'end') {
$target_order = ($max !== null) ? (int)$max + 1 : 0; $stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
$stmtMax->execute([$holiday_id]);
$max = $stmtMax->fetchColumn();
$target_order = ($max !== null) ? (int)$max + 1 : 0;
} else {
$target_order = (int)$insert_after + 1;
// On décale toutes les étapes suivantes vers le bas
$pdo->prepare("UPDATE pf_holidays_items SET sort_order = sort_order + 1 WHERE holiday_id = ? AND sort_order >= ?")->execute([$holiday_id, $target_order]);
}
} }
// 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,59 +22,58 @@ $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) { if ($id) {
// UPDATE $stmtOld = $pdo->prepare("SELECT title, image_url FROM pf_holidays WHERE id = ?");
$sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=? WHERE id=?"; $stmtOld->execute([$id]);
$stmt = $pdo->prepare($sql); if ($row = $stmtOld->fetch()) {
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $id]); $old_title = $row['title'];
} else { $image_url = $row['image_url'];
// INSERT }
$sql = "INSERT INTO pf_holidays (title, period_hint, start_date, end_date, status, budget_food, budget_extra, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes]);
$id = $pdo->lastInsertId();
} }
// GESTION INTELLIGENTE DES ITEMS // 2. Appel à Pixabay si nouveau voyage ou si le titre a changé
if (!empty($_POST['items']['name'])) { if (!$id || strcasecmp($old_title ?? '', $title) !== 0) {
$count = count($_POST['items']['name']); $apiKey = '56931941-a86fbea4e14b88712cc1e9ed9';
// On prépare une requête qui met à jour si l'ID existe, sinon insère $query = urlencode($title);
$stmtItem = $pdo->prepare(" // On force des photos horizontales HD
INSERT INTO pf_holidays_items (id, holiday_id, category, name, amount, is_paid, location_name) $url = "https://pixabay.com/api/?key={$apiKey}&q={$query}&image_type=photo&orientation=horizontal&min_width=1280&per_page=3";
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
category = VALUES(category),
name = VALUES(name),
amount = VALUES(amount),
is_paid = VALUES(is_paid)
");
$keepIds = []; $ch = curl_init();
for ($i = 0; $i < $count; $i++) { curl_setopt($ch, CURLOPT_URL, $url);
$itemId = !empty($_POST['items']['id'][$i]) ? (int)$_POST['items']['id'][$i] : null; curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$cat = $_POST['items']['cat'][$i] ?? 'activity'; curl_setopt($ch, CURLOPT_TIMEOUT, 3); // Timeout de 3s max pour ne pas bloquer l'UI
$name = trim($_POST['items']['name'][$i] ?? ''); $response = curl_exec($ch);
$amount = floatval($_POST['items']['amount'][$i] ?? 0); curl_close($ch);
$paid = (int)($_POST['items']['paid'][$i] ?? 0);
$loc = !empty($_POST['items']['location'][$i]) ? $_POST['items']['location'][$i] : null; if ($response) {
$data = json_decode($response, true);
if (!empty($name)) { if (!empty($data['hits'][0]['largeImageURL'])) {
$stmtItem->execute([$itemId, $id, $cat, $name, $amount, $paid, $loc]); $image_url = $data['hits'][0]['largeImageURL'];
$keepIds[] = $itemId ?: $pdo->lastInsertId();
} }
} }
}
// Nettoyage : On supprime les items qui ont été retirés de la modale // 3. Sauvegarde en Base
// (Attention : uniquement ceux du voyage actuel qui ne sont plus dans la liste envoyée) if ($id) {
if (!empty($keepIds)) { // UPDATE
$placeholders = implode(',', array_fill(0, count($keepIds), '?')); $sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=?, vehicle_id=?, image_url=? WHERE id=?";
$sqlDel = "DELETE FROM pf_holidays_items WHERE holiday_id = ? AND id NOT IN ($placeholders)"; $stmt = $pdo->prepare($sql);
$pdo->prepare($sqlDel)->execute(array_merge([$id], $keepIds)); $stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $image_url, $id]);
} } else {
// INSERT
$sql = "INSERT INTO pf_holidays (title, period_hint, start_date, end_date, status, budget_food, budget_extra, notes, vehicle_id, image_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $image_url]);
$id = $pdo->lastInsertId();
} }
$pdo->commit(); $pdo->commit();
@@ -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']);
}
+641 -255
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,211 +77,259 @@ $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;"> <script id="holidayDataJson" type="application/json">
<a href="?tab=list" class="pf-btn btn-secondary pf-btn-small" style="width: fit-content; text-decoration: none;"><?= tr('btn_back') ?></a> <?= json_encode(['main' => $holiday, 'items' => $generalItems], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>
<div style="display: flex; align-items: center; gap: 15px;"> </script>
<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> <!-- 1. HERO HEADER IMMERSIF -->
<script id="holidayDataJson" type="application/json"> <div class="hol-hero-banner">
<?= json_encode(['main' => $holiday, 'items' => $generalItems], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?> <img src="<?= $bgImage ?>" class="hol-hero-bg-img" alt="">
</script> <div class="hol-hero-overlay"></div>
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="editHoliday(JSON.parse(document.getElementById('holidayDataJson').textContent))"> <div class="hol-hero-content">
<?= tr('btn_edit_bases') ?>
</button> <div class="hol-hero-top">
</div> <a href="?tab=list" class="hol-hero-back-link">◀ <?= tr('btn_back') ?></a>
</div> <span class="hol-hero-status-badge"><?= tr('hdl_status_' . $holiday['status']) ?></span>
<div class="hol-summary-card">
<div class="hol-summary-grid">
<div class="hol-summary-item">
<div class="hol-summary-label"><?= tr('hdl_label_period') ?></div>
<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">
<span class="hol-legend-item" title="<?= tr('hdl_outbound') ?>"><span class="hol-legend-color aller"></span> <?= tr('hdl_outbound') ?></span>
<span class="hol-legend-item" title="<?= tr('hdl_return') ?>"><span class="hol-legend-color retour"></span> <?= tr('hdl_return') ?></span>
</div>
</div>
<button class="pf-btn pf-btn-small" onclick="openCheckpointModal('add')"><?= tr('hdl_btn_add_step') ?></button>
</div>
<div id="tripMap" style="flex:1; width:100%; min-height: 400px; background:#f1f5f9; position: relative; z-index: 1;"></div> </div>
<div class="hol-panel">
<div class="hol-panel-header">
<h3 style="margin:0;"><?= tr('hdl_step_details') ?></h3>
</div> </div>
<div class="hol-panel-body"> <h1 class="hol-hero-title"><?= htmlspecialchars($holiday['title']) ?></h1>
<?php if (empty($steps)): ?> <div class="hol-hero-date-badge">
<p style="color:var(--text-muted); font-style:italic; text-align:center; margin-top:40px;"><?= tr('hdl_no_steps') ?></p> 🗓️ <?= $dateDisplay ?>
<?php else: ?> </div>
<?php if (!empty($generalItems) || $holiday['budget_food'] > 0 || $holiday['budget_extra'] > 0): ?> <!-- LES PILLS (Indicateurs Route & Quotidien) -->
<div class="hol-checkpoint" style="border-left-color: #64748b; background: #f8fafc; margin-bottom: 20px;"> <div class="hol-hero-pills">
<div class="hol-cp-header" style="background: transparent;">
<div class="hol-cp-info-group"> <!-- PILL ROUTE (Masqué par défaut, géré par OSRM) -->
<span style="font-size:1.4rem;">🌍</span> <span id="block_total_distance" class="hol-pill-block hol-hidden">
<div class="hol-cp-title"><?= tr('hdl_general_costs') ?></div> <span class="hol-pill">
</div> 🛣️ <span id="global_total_distance">0</span> km (⏱️ <span id="global_total_duration">0h00</span>)
<?php </span>
$generalTotal = $holiday['budget_food'] + $holiday['budget_extra']; </span>
foreach ($generalItems as $gi) { $generalTotal += $gi['amount']; }
?> <!-- PILL PÉAGES -->
<div class="hol-cp-actions-group"> <span class="hol-pill">
<div class="hol-cp-total"><?= number_format($generalTotal, 2, ',', ' ') ?> €</div> 💳 <?= number_format($holiday['total_tolls'], 0) ?> €
<button onclick='editHoliday(JSON.parse(document.getElementById("holidayDataJson").textContent))' class="btn-icon-small" title="<?= tr('btn_edit') ?>">⚙️</button> </span>
</div>
</div>
<div class="hol-cp-body"> <!-- PILL ESSENCE (Avec Edit + Oeil) -->
<?php if ($holiday['budget_food'] > 0): ?> <span class="hol-pill">
<div class="hol-expense-wrapper"><div class="hol-expense-main"> ⛽ <span id="global_fuel_cost"><?= number_format($holiday['total_fuel'], 0) ?></span> €
<span class="hol-expense-name" style="color:#64748b;">🍔 <?= tr('hdl_food_bev') ?></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>
<span><strong class="hol-expense-amount"><?= number_format($holiday['budget_food'], 2, ',', ' ') ?> €</strong><span class="status-pending">⏳</span></span> <?php if ($holiday['total_fuel'] > 0 || $holiday['total_tolls'] > 0): ?>
</div></div> <span onclick="openTransitModal()" class="hol-pill-action" title="Voir le détail des trajets">👁️</span>
<?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 endif; ?>
</span>
<?php foreach ($steps as $step): ?> <!-- PILL BURGER (Food) -->
<div id="step-card-<?= $step['sort_order'] ?>" class="hol-checkpoint hol-checkpoint-draggable" draggable="true" data-location="<?= htmlspecialchars($step['location_name']) ?>"> <span class="hol-pill">
<div class="hol-cp-header"> 🍔 <?= number_format($holiday['budget_food'], 0) ?> €
<div class="hol-cp-info-group"> </span>
<span class="desktop-only" style="color:#94a3b8; font-size:1.1rem; cursor:grab; user-select:none;">☰</span>
<div class="mobile-only" style="flex-direction:column; gap:4px; margin-right:8px;"> <!-- PILL CADEAU (Extras) -->
<button type="button" onclick="moveStepMobile(this, -1)" class="btn-icon-small" style="width:24px!important; height:24px!important; font-size:0.6rem;">▲</button> <span class="hol-pill">
<button type="button" onclick="moveStepMobile(this, 1)" class="btn-icon-small" style="width:24px!important; height:24px!important; font-size:0.6rem;">▼</button> 🎁 <?= 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>
</div>
<!-- 3. LA CARTE (Masquée par défaut) -->
<div id="tripMapWrapper" class="hol-map-wrapper hol-hidden">
<div class="hol-panel-header hol-map-panel-header">
<h3 class="hol-map-title">Itinéraire</h3>
<div class="hol-map-legend">
<span class="hol-legend-item"><span class="hol-legend-color aller"></span> Aller</span>
<span class="hol-legend-item"><span class="hol-legend-color retour"></span> Retour</span>
</div>
</div>
<!-- IMPORTANT : On garde l'ID tripMap pour que Leaflet (OSRM) fonctionne ! -->
<div id="tripMap" class="hol-map-canvas"></div>
</div>
<!-- 4. TIMELINE (Carnet de route Pleine Largeur) -->
<div class="hol-panel hol-panel-timeline">
<div class="hol-panel-header hol-timeline-header">
<h3><?= tr('hdl_step_details') ?></h3>
<button class="pf-btn pf-btn-small" onclick="openCheckpointModal('add')">+ <?= tr('hdl_btn_add_step') ?></button>
</div>
<div class="hol-panel-body">
<?php if (empty($steps)): ?>
<p class="hol-empty-steps-text"><?= tr('hdl_no_steps') ?></p>
<?php else: ?>
<?php foreach ($steps as $step): ?>
<div id="step-card-<?= $step['sort_order'] ?>" class="hol-checkpoint hol-checkpoint-draggable" draggable="true" data-location="<?= htmlspecialchars($step['location_name']) ?>">
<div class="hol-step-header">
<div class="hol-step-row-top">
<?php
$stepIcon = '📍';
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 class="hol-step-title" onclick="panMapTo(<?= $step['lat'] ?>, <?= $step['lng'] ?>)" title="<?= htmlspecialchars($step['location_name']) ?>">
<?= htmlspecialchars($step['location_name']) ?>
</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 if (!empty($step['step_start_date']) && !empty($step['step_end_date'])): ?>
<span class="hol-step-date">
<?= date('d/m', strtotime($step['step_start_date'])) ?> ➔ <?= date('d/m', strtotime($step['step_end_date'])) ?>
</span>
<div class="hol-weather-info"></div>
<?php endif; ?>
</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>
<div class="hol-cp-body">
<?php
$visibleItemsCount = 0;
foreach ($step['items'] as $it):
if ($it['name'] === 'PF_TECHNICAL_POINT') continue;
$visibleItemsCount++;
$icon = match($it['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
// On vérifie si l'activité possède ses propres coordonnées GPS
$hasCustomGps = (!empty($it['lat']) && !empty($it['lng']) && ((float)$it['lat'] !== (float)$step['lat'] || (float)$it['lng'] !== (float)$step['lng']));
?>
<div class="hol-expense-wrapper">
<div class="hol-expense-main">
<span class="hol-expense-name">
<?= $icon ?>
<?php if ($hasCustomGps): ?>
<span onclick="openGpsModal(<?= $it['lat'] ?>, <?= $it['lng'] ?>)" class="hol-custom-gps-link" title="Y aller (GPS)">
<?= htmlspecialchars($it['name']) ?>
</span>
<?php else: ?>
<?= htmlspecialchars($it['name']) ?>
<?php endif; ?>
</span>
<span>
<strong class="hol-expense-amount"><?= number_format($it['amount'], 2, ',', ' ') ?> €</strong>
<span class="<?= $it['is_paid'] ? 'status-paid' : 'status-pending' ?>" title="<?= $it['is_paid'] ? tr('hdl_paid') : tr('hdl_to_pay') ?>"><?= $it['is_paid'] ? '✓' : '⏳' ?></span>
</span>
</div> </div>
<div class="hol-cp-title" onclick="panMapTo(<?= $step['lat'] ?>, <?= $step['lng'] ?>)" title="<?= htmlspecialchars($step['location_name']) ?>"> <?php if (!empty($it['notes'])): ?>
📍 <?= htmlspecialchars($step['location_name']) ?> <div class="hol-expense-note">
<?php if (!empty($step['is_return'])): ?> <?= htmlspecialchars($it['notes']) ?>
<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>
<?php endif; ?>
<?php if (!empty($step['step_start_date']) && !empty($step['step_end_date'])): ?>
<div class="hol-date-weather-wrapper">
<span class="hol-step-dates">
<?= tr('hdl_from') ?> <?= date('d/m', strtotime($step['step_start_date'])) ?> <?= tr('hdl_to') ?> <?= date('d/m', strtotime($step['step_end_date'])) ?>
</span>
<div class="hol-weather-info"></div>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> <?php endforeach; ?>
<div class="hol-cp-actions-group">
<div class="hol-cp-total"><?= number_format($step['total_amount'], 2, ',', ' ') ?> €</div> <?php if ($visibleItemsCount === 0): ?>
<button onclick='openPlanningModal(<?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="<?= tr('hdl_view_planning') ?>">📅</button> <div class="hol-empty-step">📍 <?= tr('hdl_passing_point') ?></div>
<button onclick='openCheckpointModal("edit", <?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="<?= tr('btn_edit') ?>">✏️</button> <?php endif; ?>
</div>
</div>
<div class="hol-cp-body">
<?php
$visibleItemsCount = 0;
foreach ($step['items'] as $it):
if ($it['name'] === 'PF_TECHNICAL_POINT') continue;
$visibleItemsCount++;
$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' ?>" title="<?= $it['is_paid'] ? tr('hdl_paid') : tr('hdl_to_pay') ?>"><?= $it['is_paid'] ? '✓' : '⏳' ?></span>
</span>
</div>
</div>
<?php endforeach; ?>
<?php if ($visibleItemsCount === 0): ?>
<div class="hol-empty-step">📍 <?= tr('hdl_passing_point') ?></div>
<?php endif; ?>
</div>
</div> </div>
<?php endforeach; ?> </div>
<?php endif; ?> <?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<div class="pf-card hol-notes-card">
<h2 class="pf-card-title">📝 <?= tr('hdl_label_notes') ?></h2>
<div class="pf-card-body">
<textarea id="holidayGlobalNotes" class="pf-input hol-notes-textarea" rows="6" placeholder="<?= tr('hdl_ph_notes') ?>"><?= htmlspecialchars($holiday['notes'] ?? '') ?></textarea>
<div class="hol-notes-actions">
<button id="btnSaveHolidayNote" class="pf-btn" onclick="saveHolidayGlobalNote(<?= (int)$_GET['id'] ?>)">
💾 <?= tr('btn_save') ?>
</button>
</div> </div>
</div> </div>
</div> </div>
<?php if (!empty($holiday['notes'])): ?>
<div class="hol-summary-card" style="margin-top: 24px; padding: 25px; border-left: 5px solid #f59e0b;">
<h3 style="margin: 0 0 15px 0; font-size: 1.2rem; color: #0f172a; display: flex; align-items: center; gap: 8px;">
📝 <?= tr('hdl_label_notes') ?>
</h3>
<div style="font-size: 0.95rem; color: #334155; white-space: pre-wrap; line-height: 1.6;">
<?= htmlspecialchars($holiday['notes']) ?>
</div>
</div>
<?php endif; ?>
</div> </div>
<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,36 +370,52 @@ $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">
<div class="form-group" style="margin-bottom:15px;"> <!-- CONTENU DE L'ONGLET INFO -->
<label class="pf-label"><?= tr('hdl_label_step_name') ?></label> <div id="cpTabInfo">
<input type="text" name="location_name" id="cp_name" class="pf-input" style="font-weight:bold; color:var(--primary);" required> <div class="form-group" style="margin-bottom:15px;">
</div> <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>
<div style="display:flex; gap:15px; margin-bottom:15px; background:#f8fafc; padding:12px; border-radius:8px;">
<div class="form-group" style="flex:1;">
<label class="pf-label"><?= tr('hdl_label_arrival') ?></label>
<input type="date" name="step_start_date" id="cp_start_date" class="pf-input">
</div> </div>
<div class="form-group" style="flex:1;">
<label class="pf-label"><?= tr('hdl_label_departure') ?></label> <div class="form-group" style="margin-bottom:15px; background:#fff7ed; padding:10px; border-radius:8px; border:1px solid #ffedd5;">
<input type="date" name="step_end_date" id="cp_end_date" class="pf-input"> <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 class="form-group" id="grp_start_date" style="flex:1;">
<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">
</div>
<div class="form-group" id="grp_end_date" style="flex:1;">
<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">
</div>
</div>
<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; font-size:0.9rem;">
<input type="checkbox" name="set_as_return" id="cp_set_as_return" value="1" style="margin-right:8px; width:16px; height:16px;">
🏁 Définir comme retour
</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> </div>
<div style="margin-bottom: 15px;"> <!-- CONTENU DE L'ONGLET PROGRAMME & FRAIS -->
<label style="display:flex; align-items:center; cursor:pointer; color:#ea580c; font-weight:600;"> <div id="cpTabProg" class="hol-hidden" style="display: none;">
<input type="checkbox" name="is_return" id="cp_is_return" value="1" style="margin-right:8px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
🏁 <?= tr('hdl_return') ?> <label class="pf-label" style="margin:0;"><?= tr('hdl_planned_expenses') ?></label>
</label> <button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="addCpExpenseLine()"><?= tr('hdl_btn_add_expense') ?></button>
</div> </div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;"> <div id="cpExpensesContainer" style="margin-bottom:15px;"></div>
<label class="pf-label" style="margin:0;"><?= tr('hdl_planned_expenses') ?></label>
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="addCpExpenseLine()"><?= tr('hdl_btn_add_expense') ?></button>
</div> </div>
<div id="cpExpensesContainer" style="margin-bottom:15px;"></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;">
<input type="checkbox" name="save_favorite" value="1" style="margin-right:8px;"> <input type="checkbox" name="save_favorite" value="1" style="margin-right:8px;">
@@ -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' ?>;
const modal = document.getElementById('checkpointModal');
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll');
};
window.closePlanningModal = window.closePlanningModal || function() { // --- 2. SURCHARGE SÉCURISÉE DES FONCTIONS JS (APRÈS CHARGEMENT DE HOLIDAYS.JS) ---
const modal = document.getElementById('planningModal'); document.addEventListener('DOMContentLoaded', function() {
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll'); // 1. Correction du conflit d'onglet
}; window.switchCpTab = function(tabId) {
const btnInfo = document.getElementById('tabBtnInfo');
const btnProg = document.getElementById('tabBtnProg');
const tabInfo = document.getElementById('cpTabInfo');
const tabProg = document.getElementById('cpTabProg');
if (tabId === 'info') {
if (btnInfo) { btnInfo.style.borderBottomColor = "var(--primary)"; btnInfo.style.color = "var(--primary)"; }
if (btnProg) { btnProg.style.borderBottomColor = "transparent"; btnProg.style.color = "var(--text-muted)"; }
if (tabInfo) { tabInfo.style.display = "block"; tabInfo.classList.remove("hol-hidden"); }
if (tabProg) { tabProg.style.display = "none"; tabProg.classList.add("hol-hidden"); }
} else {
if (btnProg) { btnProg.style.borderBottomColor = "var(--primary)"; btnProg.style.color = "var(--primary)"; }
if (btnInfo) { btnInfo.style.borderBottomColor = "transparent"; btnInfo.style.color = "var(--text-muted)"; }
if (tabProg) { tabProg.style.display = "block"; tabProg.classList.remove("hol-hidden"); }
if (tabInfo) { tabInfo.style.display = "none"; tabInfo.classList.add("hol-hidden"); }
}
};
// 2. Correction modale : ajout de l'injection lat/lng aux items
window.openCheckpointModal = function(mode, data = null) {
const searchBlock = document.getElementById("cpSearchBlock");
const formBlock = document.getElementById("formCheckpoint");
const container = document.getElementById("cpExpensesContainer");
const btnDel = document.getElementById("btnDeleteCp");
const modalTitle = document.getElementById("cpModalTitle");
if(container) container.innerHTML = "";
if (document.getElementById("cp_start_date")) document.getElementById("cp_start_date").value = "";
if (document.getElementById("cp_end_date")) document.getElementById("cp_end_date").value = "";
if (document.getElementById("searchPlaceInput")) document.getElementById("searchPlaceInput").value = "";
if (document.getElementById("searchResults")) document.getElementById("searchResults").innerHTML = "";
window.switchCpTab('info');
if (mode === "add") {
if (modalTitle) modalTitle.innerText = "📍 " + (window.I18N['hdl_btn_add_step'] || "Ajouter une étape");
if (searchBlock) searchBlock.style.display = "block";
if (formBlock) formBlock.style.display = "block";
if (btnDel) btnDel.style.display = "none";
if (document.getElementById("cp_old_sort_order")) document.getElementById("cp_old_sort_order").value = "";
if (document.getElementById("cp_name")) document.getElementById("cp_name").value = "";
if (document.getElementById("cp_step_type")) {
document.getElementById("cp_step_type").value = "stop";
if (typeof toggleStepDates === 'function') toggleStepDates("stop");
}
if (document.getElementById("cp_set_as_return")) document.getElementById("cp_set_as_return").checked = false;
if (typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
} else if (mode === "edit" && data) {
if (modalTitle) modalTitle.innerText = "✏️ " + (window.I18N['hdl_js_edit_step'] || "Modifier l'étape");
if (searchBlock) searchBlock.style.display = "none";
if (formBlock) formBlock.style.display = "block";
if (btnDel) btnDel.style.display = "block";
if (document.getElementById("cp_lat")) document.getElementById("cp_lat").value = data.lat;
if (document.getElementById("cp_lng")) document.getElementById("cp_lng").value = data.lng;
if (document.getElementById("cp_old_sort_order")) document.getElementById("cp_old_sort_order").value = data.sort_order;
if (document.getElementById("cp_name")) document.getElementById("cp_name").value = data.location_name;
if (document.getElementById("cp_start_date")) document.getElementById("cp_start_date").value = data.step_start_date || "";
if (document.getElementById("cp_end_date")) document.getElementById("cp_end_date").value = data.step_end_date || "";
if (document.getElementById("cp_step_type")) {
const type = data.step_type || "stop";
document.getElementById("cp_step_type").value = type;
if (typeof toggleStepDates === 'function') toggleStepDates(type);
}
if (document.getElementById("cp_set_as_return")) {
document.getElementById("cp_set_as_return").checked = (window.GLOBAL_RETURN_STEP_ID == data.sort_order);
}
// Génération avec transmission de la latitude et longitude de chaque dépense
if (data.items && data.items.length > 0) {
let visibleCount = 0;
data.items.forEach((it) => {
if (it.name !== "PF_TECHNICAL_POINT") {
if (typeof window.addCpExpenseLine === 'function') {
window.addCpExpenseLine(
it.category, it.name, it.amount, it.is_paid, it.notes || "",
it.id || "", it.item_date || "", it.item_time || "", it.duration || 1,
it.expense_context || "local", it.lat || "", it.lng || ""
);
}
visibleCount++;
}
});
if (visibleCount === 0 && typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
} else {
if (typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
}
}
try {
if (typeof cpDateRangePicker !== 'undefined' && cpDateRangePicker && typeof cpDateRangePicker.destroy === 'function') {
cpDateRangePicker.destroy();
}
} catch (e) {}
const modal = document.getElementById("checkpointModal");
if (modal) {
modal.style.display = "flex";
document.body.classList.add("no-scroll");
}
};
// 3. LA FONCTION AVEC LA CLASSE CSS PARFAITE ET LE BOUTON 📍
window.addCpExpenseLine = function(category = "accommodation", name = "", amount = "", isPaid = 0, notes = "", itemId = "", itemDate = "", itemTime = "", itemDur = 1, expenseContext = "local", itemLat = "", itemLng = "") {
const container = document.getElementById("cpExpensesContainer");
if (!container) return;
const div = document.createElement("div");
div.className = "hol-form-row"; // Force l'utilisation du CSS
const isChecked = isPaid == 1 ? "checked" : "";
// Indicateur visuel (bouton vert) si on a déjà des coordonnées pour cette activité
const pinBtnStyle = (itemLat && itemLng) ? "background:var(--success);color:white;border-color:var(--success);" : "";
div.innerHTML = `
<div class="hol-form-inner">
<select name="items[cat][]" class="pf-input hol-form-select">
<option value="accommodation" ${category === "accommodation" ? "selected" : ""}>🏨</option>
<option value="transport" ${category === "transport" ? "selected" : ""}>🚗</option>
<option value="activity" ${category === "activity" ? "selected" : ""}>🎫</option>
</select>
<input type="hidden" name="items[context][]" value="${expenseContext}">
<input type="text" name="items[name][]" class="pf-input hol-form-text" placeholder="${window.I18N['hdl_js_ph_expense_name'] || 'Nom de la dépense'}" value="${name}" required>
<button type="button" class="btn-icon-action" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border-light);border-radius:6px; margin:0; flex-shrink:0; ${pinBtnStyle}" onclick="window.toggleItemSearch(this)" title="Adresse précise">📍</button>
<input type="number" step="0.01" name="items[amount][]" class="pf-input hol-form-number" placeholder="0.00" value="${amount}" required>
<label class="hol-form-paid-label" title="${window.I18N['hdl_paid'] || 'Payé'}">
<input type="checkbox" ${isChecked} onchange="this.nextElementSibling.value = this.checked ? 1 : 0" style="width:16px;height:16px;margin:0;accent-color:var(--primary);">
<input type="hidden" name="items[paid][]" value="${isPaid}">
<span class="hol-form-paid-text">Payé</span>
</label>
<button type="button" onclick="this.parentElement.parentElement.remove()" title="${window.I18N['hdl_js_delete_line'] || 'Supprimer'}" class="btn-icon-action delete btn-remove-expense" style="margin: 0;">🗑️</button>
</div>
<div class="hol-item-search-box" style="display:none; padding: 10px; background: var(--bg-subtle); border-radius: 8px; margin-top: 6px; width: 100%;">
<div style="display:flex; gap:10px;">
<input type="text" class="pf-input item-search-input" placeholder="Chercher une adresse..." onkeypress="if(event.key === 'Enter') { window.searchItemPlace(this); return false; }">
<button type="button" class="pf-btn btn-secondary" onclick="window.searchItemPlace(this)">🔍</button>
</div>
<div class="item-search-results" style="margin-top:10px; max-height:150px; overflow-y:auto;"></div>
</div>
<div class="hol-form-subrow" style="display: flex; padding-left: 58px; margin-top: 6px;">
<input type="text" name="items[notes][]" class="pf-input hol-form-notes-input" placeholder="${window.I18N['hdl_ph_notes'] || 'Notes...'}" value="${notes}" style="font-size: 0.8rem; padding: 6px 8px; border-style: dashed; color: var(--text-muted); width: 100%; background: transparent; margin: 0;">
</div>
<input type="hidden" name="items[id][]" value="${itemId}">
<input type="hidden" name="items[date][]" value="${itemDate}">
<input type="hidden" name="items[time][]" value="${itemTime}">
<input type="hidden" name="items[duration][]" value="${itemDur}">
<input type="hidden" name="items[lat][]" value="${itemLat}">
<input type="hidden" name="items[lng][]" value="${itemLng}">
`;
container.appendChild(div);
};
// 4. RÉINTÉGRATION DES FONCTIONS DE GÉOCODAGE DANS LE MÊME SCOPE
window.toggleItemSearch = function(btn) {
const searchBox = btn.closest(".hol-form-row").querySelector(".hol-item-search-box");
searchBox.style.display = searchBox.style.display === "none" ? "block" : "none";
};
window.searchItemPlace = async function(btn) {
const container = btn.closest(".hol-item-search-box");
const input = container.querySelector(".item-search-input");
const resultsDiv = container.querySelector(".item-search-results");
const q = input.value.trim();
if (q.length < 3) return;
resultsDiv.innerHTML = `<span style="color:#64748b; font-size:0.85rem;">Recherche en cours...</span>`;
try {
const data = await pachaFetch("/modules/holidays/includes/api/geocode.php?limit=5&q=" + encodeURIComponent(q), { method: 'GET' });
resultsDiv.innerHTML = "";
if (data.error || !data.results || data.results.length === 0) {
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">Aucun résultat</span>`;
return;
}
data.results.forEach((place) => {
const b = document.createElement("button");
b.type = "button";
b.className = "pf-btn btn-secondary";
b.style.textAlign = "left";
b.style.padding = "8px";
b.style.marginBottom = "4px";
b.style.width = "100%";
b.innerText = "📍 " + place.display_name;
b.onclick = () => window.selectItemPlace(container, place.lat, place.lng, place.display_name);
resultsDiv.appendChild(b);
});
} catch (err) {
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">Erreur de connexion</span>`;
}
};
window.selectItemPlace = function(container, lat, lng, displayName) {
const row = container.closest(".hol-form-row");
row.querySelector('input[name="items[lat][]"]').value = lat;
row.querySelector('input[name="items[lng][]"]').value = lng;
// Si le champ note est vide, on l'utilise pour y placer le nom complet de l'adresse
const notesInput = row.querySelector('input[name="items[notes][]"]');
if (notesInput.value === "") {
notesInput.value = displayName;
}
container.style.display = "none";
// Le bouton devient vert pour confirmer la présence de coordonnées
const pinBtn = row.querySelector('.btn-icon-action[title="Adresse précise"]');
if (pinBtn) {
pinBtn.style.background = "var(--success)";
pinBtn.style.color = "white";
pinBtn.style.borderColor = "var(--success)";
}
};
window.closeCheckpointModal = function() {
const modal = document.getElementById('checkpointModal');
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll');
};
window.closePlanningModal = function() {
const modal = document.getElementById('planningModal');
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll');
};
});
</script> </script>
<script src="/modules/holidays/holidays.js"></script>
+6 -4
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']) {
@@ -180,7 +182,7 @@ function renderHolidayCard($h, $pdo) {
<span>🗓️ ".($dateDisplay ?: tr('hdl_dates_to_define'))."</span> <span>🗓️ ".($dateDisplay ?: tr('hdl_dates_to_define'))."</span>
</div> </div>
<div style='margin-top:auto; padding-top:15px; border-top:1px solid #f1f5f9;'> <div style='margin-top:auto; padding-top:15px; border-top:1px solid var(--border-light);'>
<div style='display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;'> <div style='display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;'>
<span style='font-size:0.85rem; color:#64748b;'>".tr('hdl_total_budget')."</span> <span style='font-size:0.85rem; color:#64748b;'>".tr('hdl_total_budget')."</span>
<span style='font-size:1.1rem; font-weight:bold; color:#1e293b;'>".number_format($cost, 0, ',', ' ')." €</span> <span style='font-size:1.1rem; font-weight:bold; color:#1e293b;'>".number_format($cost, 0, ',', ' ')." €</span>
+23 -48
View File
@@ -1,5 +1,5 @@
<div id="holidayModal" class="pf-modal"> <div id="holidayModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 800px; width: 95%;"> <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">
@@ -7,11 +7,11 @@
<input type="hidden" name="action" value="save"> <input type="hidden" name="action" value="save">
<div class="form-row"> <div class="form-row">
<div style="flex: 2;"> <div class="hol-flex-2">
<label class="pf-label"><?= tr('hdl_label_name') ?></label> <label class="pf-label"><?= tr('hdl_label_name') ?></label>
<input type="text" name="title" id="inp_title" class="pf-input" placeholder="<?= tr('hdl_ph_name') ?>" required> <input type="text" name="title" id="inp_title" class="pf-input" placeholder="<?= tr('hdl_ph_name') ?>" required>
</div> </div>
<div style="flex: 1;"> <div class="hol-flex-1">
<label class="pf-label"><?= tr('hdl_label_status') ?></label> <label class="pf-label"><?= tr('hdl_label_status') ?></label>
<select name="status" id="inp_status" class="pf-input"> <select name="status" id="inp_status" class="pf-input">
<option value="draft"><?= tr('hdl_status_draft') ?> ✏️</option> <option value="draft"><?= tr('hdl_status_draft') ?> ✏️</option>
@@ -24,70 +24,45 @@
</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 style="display:flex; gap:10px;"> <!-- DATES AVEC FLATPICKR -->
<div style="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 style="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 style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;"> <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 style="color:#2563eb;">🚗 <?= 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 style="color:#059669;">🏨 <?= 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 style="color:#d97706;">🎫 <?= 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 style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;"> <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" style="color:#ef4444; border-color:#fca5a5; margin-right:auto; display:none;"><?= 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>
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button> <button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</div> </div>
+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>
+8 -51
View File
@@ -10,7 +10,7 @@ body[data-page="home"] {
background-repeat: no-repeat; background-repeat: no-repeat;
/* Couleur de texte par défaut sur la home (Blanc) */ /* Couleur de texte par défaut sur la home (Blanc) */
color: #ffffff; color: var(--text-on-primary);
} }
/* Voile sombre pour garantir la lisibilité du texte blanc */ /* Voile sombre pour garantir la lisibilité du texte blanc */
@@ -18,51 +18,13 @@ body[data-page="home"]::before {
content: ""; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(15, 23, 42, 0.5); /* Bleu nuit semi-transparent */ background: var(--bg-overlay); /* Bleu nuit semi-transparent */
z-index: -1; /* Derrière le contenu */ z-index: -1; /* Derrière le contenu */
} }
/* === 2. HEADER SPÉCIFIQUE (Translucide) === */ /* Header : identique au reste du site (global.css + dark-mode) */
body[data-page="home"] .pf-header {
background: rgba(36, 59, 83, 0.85); /* Ton ancien bleu sombre */
backdrop-filter: blur(8px); /* Effet verre dépoli */
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
/* Adaptation des couleurs des liens du header sur fond sombre */ /* === 2. HERO SECTION (Message de Bienvenue) === */
body[data-page="home"] .pf-logo {
color: #ffffff;
}
body[data-page="home"] .pf-burger-btn {
color: #ffffff;
}
body[data-page="home"] .pf-nav-link {
color: rgba(255, 255, 255, 0.8);
}
body[data-page="home"] .pf-nav-link:hover {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
body[data-page="home"] .pf-nav-link--active {
background: rgba(255, 255, 255, 0.9);
color: #243b53; /* Texte foncé sur fond blanc */
}
/* Badge utilisateur */
body[data-page="home"] .pf-user-badge {
color: rgba(255, 255, 255, 0.9);
}
body[data-page="home"] .pf-logout-btn {
color: #fca5a5;
} /* Rouge clair */
body[data-page="home"] .pf-logout-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #fca5a5;
}
/* === 3. HERO SECTION (Message de Bienvenue) === */
.pf-hero { .pf-hero {
margin-top: 40px; margin-top: 40px;
margin-bottom: 60px; margin-bottom: 60px;
@@ -70,7 +32,7 @@ body[data-page="home"] .pf-logout-btn:hover {
} }
.pf-hero h1 { .pf-hero h1 {
color: #ffffff; color: var(--text-on-primary);
font-size: 2.5rem; font-size: 2.5rem;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); /* Ombre pour détacher le texte de l'image */ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); /* Ombre pour détacher le texte de l'image */
margin-bottom: 12px; margin-bottom: 12px;
@@ -83,7 +45,7 @@ body[data-page="home"] .pf-logout-btn:hover {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
} }
/* === 4. GRILLE DES MODULES === */ /* === 3. GRILLE DES MODULES === */
/* Titre de section "Modules" */ /* Titre de section "Modules" */
body[data-page="home"] .pf-modules-grid { body[data-page="home"] .pf-modules-grid {
@@ -100,7 +62,7 @@ body[data-page="home"] .pf-modules-grid {
} }
.pf-module-card:hover { .pf-module-card:hover {
background: #ffffff; background: var(--bg-panel);
transform: translateY(-5px); transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
} }
@@ -113,7 +75,7 @@ body[data-page="home"] .pf-modules-grid {
color: var(--text-muted); color: var(--text-muted);
} }
/* === 5. MOBILE RESPONSIVE === */ /* === 4. MOBILE RESPONSIVE === */
@media (max-width: 768px) { @media (max-width: 768px) {
body[data-page="home"] { body[data-page="home"] {
/* Image spécifique mobile */ /* Image spécifique mobile */
@@ -131,9 +93,4 @@ body[data-page="home"] .pf-modules-grid {
.pf-hero h1 { .pf-hero h1 {
font-size: clamp(1.8rem, 6vw, 2.5rem); /* Typographie fluide */ font-size: clamp(1.8rem, 6vw, 2.5rem); /* Typographie fluide */
} }
/* Ajustement header mobile */
body[data-page="home"] .pf-header {
padding: 0 16px;
}
} }
+209
View File
@@ -0,0 +1,209 @@
<?php
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
set_exception_handler(function(\Throwable $e) {
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
});
require_once dirname(__DIR__, 2) . '/includes/db.php';
$UPLOAD_DIR = '/uploads/memo/';
if (!is_dir($UPLOAD_DIR)) @mkdir($UPLOAD_DIR, 0755, true);
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
function mOk($d) { echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit; }
function mErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
function mBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
function parseTags(string $raw): string {
$tags = array_unique(array_filter(array_map(function($t) {
return strtolower(trim(ltrim($t, '#'), " \t\n\r,"));
}, preg_split('/[\s,]+/', $raw))));
return implode(',', $tags);
}
function imgExts(): array { return ['jpg','jpeg','png','gif','webp','svg','bmp']; }
function isImage(string $fname): bool { return in_array(strtolower(pathinfo($fname, PATHINFO_EXTENSION)), imgExts()); }
function handleUpload(string $field, string $dir): ?array {
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) return null;
$orig = $_FILES[$field]['name'];
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
$allowed = array_merge(imgExts(), ['pdf','doc','docx','txt','csv','zip','mp3','mp4']);
if (!in_array($ext, $allowed)) return null;
$fname = uniqid('m', true) . '.' . $ext;
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) return null;
return ['filename' => $fname, 'original_name' => $orig, 'size' => filesize($dir . $fname)];
}
// ── NOTES ─────────────────────────────────────────────────────────────────────
if ($action === 'notes') {
if ($method === 'GET') {
$id = $_GET['id'] ?? null;
$q = trim($_GET['q'] ?? '');
$tag = trim($_GET['tag'] ?? '');
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 24;
$offset = ($page - 1) * $perPage;
if ($id) {
$n = $pdo->prepare("SELECT * FROM pf_memo_notes WHERE id = ?"); $n->execute([$id]);
$note = $n->fetch(); if (!$note) mErr('Note introuvable', 404);
$a = $pdo->prepare("SELECT * FROM pf_memo_attachments WHERE note_id = ? ORDER BY created_at");
$a->execute([$id]); $note['attachments'] = $a->fetchAll();
mOk($note);
}
if ($q) {
$like = '%' . $q . '%';
$stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
FROM pf_memo_notes WHERE title LIKE ? OR content LIKE ? OR tags LIKE ?
ORDER BY updated_at DESC LIMIT ? OFFSET ?");
$stmt->execute([$like, $like, $like, $perPage, $offset]);
$total = $pdo->prepare("SELECT COUNT(*) FROM pf_memo_notes WHERE title LIKE ? OR content LIKE ? OR tags LIKE ?");
$total->execute([$like, $like, $like]);
} elseif ($tag) {
$stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
FROM pf_memo_notes WHERE FIND_IN_SET(?, tags) > 0
ORDER BY updated_at DESC LIMIT ? OFFSET ?");
$stmt->execute([$tag, $perPage, $offset]);
$total = $pdo->prepare("SELECT COUNT(*) FROM pf_memo_notes WHERE FIND_IN_SET(?, tags) > 0");
$total->execute([$tag]);
} else {
$stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
FROM pf_memo_notes ORDER BY updated_at DESC LIMIT ? OFFSET ?");
$stmt->execute([$perPage, $offset]);
$total = $pdo->query("SELECT COUNT(*) FROM pf_memo_notes");
}
mOk(['notes' => $stmt->fetchAll(), 'total' => (int)$total->fetchColumn(),
'page' => $page, 'per_page' => $perPage]);
}
if ($method === 'POST') {
$d = $_POST ?: mBody();
$title = trim($d['title'] ?? '');
if (!$title) mErr('Titre requis');
$tags = parseTags($d['tags'] ?? '');
$pdo->prepare("INSERT INTO pf_memo_notes (title, content, tags) VALUES (?,?,?)")
->execute([$title, $d['content'] ?? '', $tags]);
$id = $pdo->lastInsertId();
// Files
if (!empty($_FILES)) {
foreach ($_FILES as $key => $f) {
if ($f['error'] === UPLOAD_ERR_OK) {
$up = handleUpload($key, $UPLOAD_DIR);
if ($up) {
$type = isImage($up['filename']) ? 'image' : 'file';
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,filename,original_name,size) VALUES (?,?,?,?,?)")
->execute([$id, $type, $up['filename'], $up['original_name'], $up['size']]);
}
}
}
}
// URLs from JSON body
foreach (($d['urls'] ?? []) as $urlItem) {
if (!empty($urlItem['url'])) {
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,url,label) VALUES (?,?,?,?)")
->execute([$id, 'url', $urlItem['url'], $urlItem['label'] ?? '']);
}
}
mOk(['id' => (int)$id]);
}
if ($method === 'PUT') {
$id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
$d = mBody();
$title = trim($d['title'] ?? '');
if (!$title) mErr('Titre requis');
$tags = parseTags($d['tags'] ?? '');
$pdo->prepare("UPDATE pf_memo_notes SET title=?, content=?, tags=?, updated_at=NOW() WHERE id=?")
->execute([$title, $d['content'] ?? '', $tags, $id]);
mOk(['updated' => true]);
}
if ($method === 'DELETE') {
$id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
$rows = $pdo->prepare("SELECT filename FROM pf_memo_attachments WHERE note_id=? AND filename IS NOT NULL");
$rows->execute([$id]);
foreach ($rows->fetchAll() as $r) @unlink($UPLOAD_DIR . $r['filename']);
$pdo->prepare("DELETE FROM pf_memo_notes WHERE id=?")->execute([$id]);
mOk(['deleted' => true]);
}
}
// ── TAGS ──────────────────────────────────────────────────────────────────────
if ($action === 'tags') {
$rows = $pdo->query("SELECT tags FROM pf_memo_notes WHERE tags != ''")->fetchAll();
$counts = [];
foreach ($rows as $r) {
foreach (explode(',', $r['tags']) as $t) {
$t = trim($t);
if ($t) $counts[$t] = ($counts[$t] ?? 0) + 1;
}
}
arsort($counts);
mOk(array_map(fn($t, $c) => ['tag' => $t, 'count' => $c], array_keys($counts), $counts));
}
// ── ATTACHMENTS ───────────────────────────────────────────────────────────────
if ($action === 'attachments') {
if ($method === 'POST') {
$id = $_POST['note_id'] ?? null; if (!$id) mErr('note_id manquant');
// File upload
if (!empty($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$up = handleUpload('file', $UPLOAD_DIR);
if (!$up) mErr('Upload échoué ou format non autorisé');
$type = isImage($up['filename']) ? 'image' : 'file';
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,filename,original_name,size) VALUES (?,?,?,?,?)")
->execute([$id, $type, $up['filename'], $up['original_name'], $up['size']]);
mOk(['id' => (int)$pdo->lastInsertId(), 'type' => $type,
'filename' => $up['filename'], 'original_name' => $up['original_name']]);
}
// URL
$url = trim($_POST['url'] ?? '');
if ($url) {
$label = trim($_POST['label'] ?? '');
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,url,label) VALUES (?,?,?,?)")
->execute([$id, 'url', $url, $label]);
mOk(['id' => (int)$pdo->lastInsertId(), 'type' => 'url', 'url' => $url, 'label' => $label]);
}
mErr('Aucun fichier ou URL fourni');
}
if ($method === 'DELETE') {
$id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
$r = $pdo->prepare("SELECT filename FROM pf_memo_attachments WHERE id=?"); $r->execute([$id]);
$row = $r->fetch();
if ($row && $row['filename']) @unlink($UPLOAD_DIR . $row['filename']);
$pdo->prepare("DELETE FROM pf_memo_attachments WHERE id=?")->execute([$id]);
mOk(['deleted' => true]);
}
}
// ── FILE SERVE ────────────────────────────────────────────────────────────────
if ($action === 'file') {
$id = $_GET['id'] ?? null;
$row = null;
if ($id) { $s = $pdo->prepare("SELECT * FROM pf_memo_attachments WHERE id=?"); $s->execute([$id]); $row = $s->fetch(); }
if (!$row || !$row['filename'] || !file_exists($UPLOAD_DIR . $row['filename'])) { http_response_code(404); exit; }
$ext = strtolower(pathinfo($row['filename'], PATHINFO_EXTENSION));
$mime = [
'jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif',
'webp'=>'image/webp','svg'=>'image/svg+xml','pdf'=>'application/pdf',
'txt'=>'text/plain','mp4'=>'video/mp4','mp3'=>'audio/mpeg',
][$ext] ?? 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Disposition: inline; filename="' . rawurlencode($row['original_name'] ?? $row['filename']) . '"');
header('Cache-Control: private, max-age=3600');
readfile($UPLOAD_DIR . $row['filename']); exit;
}
mErr('Action inconnue', 404);
+279
View File
@@ -0,0 +1,279 @@
/* Memo module */
.memo-layout {
display: grid;
grid-template-columns: 220px 1fr;
height: calc(100vh - 64px);
overflow: hidden;
}
.memo-sidebar {
border-right: 1px solid var(--border-light);
background: var(--bg-panel);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0;
}
.memo-sidebar-top {
padding: 1rem;
border-bottom: 1px solid var(--border-light);
display: flex;
flex-direction: column;
gap: .6rem;
position: sticky;
top: 0;
background: var(--bg-panel);
z-index: 1;
}
.memo-search {
display: flex;
align-items: center;
gap: .4rem;
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 8px;
padding: .4rem .6rem;
}
.memo-search input {
border: none;
background: transparent;
color: var(--text-main);
font-size: .875rem;
outline: none;
flex: 1;
min-width: 0;
}
.memo-search input::placeholder { color: var(--text-muted); }
.memo-tags-list { padding: .5rem 0; }
.memo-tag-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: .4rem 1rem;
cursor: pointer;
font-size: .82rem;
color: var(--text-muted);
border-radius: 0;
transition: background .12s;
text-decoration: none;
}
.memo-tag-item:hover { background: var(--bg-page); color: var(--text-main); }
.memo-tag-item.active { background: #eff6ff; color: var(--primary); font-weight: 600; }
.memo-tag-count {
font-size: .72rem;
background: var(--bg-page);
color: var(--text-muted);
padding: 1px 6px;
border-radius: 999px;
min-width: 20px;
text-align: center;
}
.memo-tag-item.active .memo-tag-count { background: #dbeafe; color: var(--primary); }
/* Main area */
.memo-main { overflow-y: auto; background: var(--bg-page); }
/* Pages */
.memo-page { display: none; }
.memo-page.active { display: block; }
/* Note list */
.memo-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-light);
gap: .75rem;
}
.memo-list-header h2 { font-size: 1rem; font-weight: 700; margin: 0; }
.memo-list-subtitle { font-size: .82rem; color: var(--text-muted); }
.notes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
padding: 1.25rem 1.5rem;
}
.note-card {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 1rem 1.1rem;
cursor: pointer;
transition: border-color .15s, box-shadow .15s, transform .1s;
display: flex;
flex-direction: column;
gap: .4rem;
text-decoration: none;
}
.note-card:hover { border-color: var(--primary); box-shadow: 0 4px 12px rgba(59,130,246,.1); transform: translateY(-1px); }
.note-card-title { font-size: .95rem; font-weight: 700; color: var(--text-main); }
.note-card-snippet { font-size: .8rem; color: var(--text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.note-card-meta { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-top: .25rem; }
.note-card-date { font-size: .72rem; color: var(--text-muted); margin-left: auto; }
.note-tag-chip {
font-size: .7rem; font-weight: 600;
background: #eff6ff; color: var(--primary);
padding: .15rem .45rem; border-radius: 999px;
cursor: pointer;
}
.note-tag-chip:hover { background: #dbeafe; }
/* Note view */
.memo-view-header {
padding: 1.5rem 1.75rem 1rem;
border-bottom: 1px solid var(--border-light);
background: var(--bg-panel);
}
.memo-view-title { font-size: 1.6rem; font-weight: 800; color: var(--text-main); margin-bottom: .5rem; line-height: 1.2; }
.memo-view-meta { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .8rem; color: var(--text-muted); }
.memo-view-actions { display: flex; gap: .5rem; margin-left: auto; }
.memo-content-area {
padding: 1.5rem 1.75rem;
background: var(--bg-panel);
margin: 1rem 1.5rem;
border: 1px solid var(--border-light);
border-radius: 12px;
}
.memo-content-area.empty { color: var(--text-muted); font-style: italic; }
/* Markdown rendered content */
.md-body { color: var(--text-main); line-height: 1.7; font-size: .95rem; }
.md-body h1,.md-body h2,.md-body h3,.md-body h4 { font-weight: 700; margin: 1.25em 0 .5em; line-height: 1.3; color: var(--text-main); }
.md-body h1 { font-size: 1.5rem; }
.md-body h2 { font-size: 1.25rem; }
.md-body h3 { font-size: 1.05rem; }
.md-body p { margin: .75em 0; }
.md-body ul,.md-body ol { padding-left: 1.5rem; margin: .75em 0; }
.md-body li { margin: .25em 0; }
.md-body code { font-family: monospace; background: #f1f5f9; color: #c7254e; padding: .1em .35em; border-radius: 4px; font-size: .88em; }
.md-body pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 1em 0; }
.md-body pre code { background: none; color: inherit; padding: 0; }
.md-body blockquote { border-left: 3px solid var(--primary); margin: 1em 0; padding: .5em 1em; background: #eff6ff; color: var(--text-muted); border-radius: 0 6px 6px 0; }
.md-body a { color: var(--primary); }
.md-body hr { border: none; border-top: 1px solid var(--border-light); margin: 1.5em 0; }
.md-body table { width: 100%; border-collapse: collapse; margin: 1em 0; }
.md-body table th { background: #f8fafc; text-align: left; padding: .5rem .75rem; border: 1px solid var(--border-light); font-weight: 600; }
.md-body table td { padding: .5rem .75rem; border: 1px solid var(--border-light); }
.md-body mark { background: #fef3c7; color: #92400e; padding: .1em .2em; border-radius: 3px; }
.md-body img { max-width: 100%; border-radius: 8px; margin: .5em 0; }
/* Attachments */
.memo-attachments { padding: 0 1.5rem 1.5rem; }
.memo-att-section { margin-bottom: 1.25rem; }
.memo-att-title { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-bottom: .6rem; }
.att-img-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: .6rem; }
.att-img-item { position: relative; border-radius: 8px; overflow: hidden; aspect-ratio: 1; background: #f1f5f9; cursor: pointer; }
.att-img-item img { width: 100%; height: 100%; object-fit: cover; }
.att-img-item .att-del { position: absolute; top: 4px; right: 4px; background: rgba(0,0,0,.5); color: #fff; border: none; border-radius: 50%; width: 22px; height: 22px; font-size: .75rem; cursor: pointer; display: none; line-height: 1; }
.att-img-item:hover .att-del { display: flex; align-items: center; justify-content: center; }
.att-file-list { display: flex; flex-direction: column; gap: .4rem; }
.att-file-item { display: flex; align-items: center; gap: .6rem; padding: .4rem .7rem; background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 8px; font-size: .82rem; }
.att-file-item a { color: var(--primary); flex: 1; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.att-file-item a:hover { text-decoration: underline; }
.att-file-size { color: var(--text-muted); font-size: .72rem; white-space: nowrap; }
.att-url-list { display: flex; flex-direction: column; gap: .4rem; }
.att-url-item { display: flex; align-items: center; gap: .6rem; padding: .4rem .7rem; background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 8px; font-size: .82rem; }
.att-url-item a { color: var(--primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Edit form */
.memo-edit-area { padding: 1.25rem 1.5rem; }
.memo-edit-form { background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 12px; padding: 1.5rem; }
.form-group { display: flex; flex-direction: column; gap: .35rem; margin-bottom: 1.1rem; }
.form-label { font-size: .82rem; font-weight: 600; color: var(--text-muted); }
.form-control { background: #fff; border: 1px solid var(--border-light); color: var(--text-main); border-radius: 8px; padding: .5rem .75rem; font-size: .875rem; width: 100%; outline: none; transition: border-color .15s, box-shadow .15s; font-family: inherit; }
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
.form-control::placeholder { color: #cbd5e1; }
.edit-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.edit-textarea { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: .82rem; min-height: 320px; resize: vertical; line-height: 1.6; }
.md-preview-box { min-height: 320px; padding: .75rem 1rem; background: #fafafa; border: 1px solid var(--border-light); border-radius: 8px; overflow-y: auto; }
.tags-input-wrap { display: flex; flex-wrap: wrap; gap: .35rem; padding: .35rem .6rem; border: 1px solid var(--border-light); border-radius: 8px; background: #fff; min-height: 42px; align-items: center; cursor: text; transition: border-color .15s; }
.tags-input-wrap:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
.tags-input-wrap input { border: none; outline: none; background: transparent; font-size: .875rem; color: var(--text-main); min-width: 80px; flex: 1; }
.tag-chip { display: inline-flex; align-items: center; gap: .25rem; background: #eff6ff; color: var(--primary); font-size: .78rem; font-weight: 600; padding: .2rem .5rem; border-radius: 999px; }
.tag-chip button { background: none; border: none; cursor: pointer; font-size: .8rem; color: var(--primary); line-height: 1; padding: 0; }
.attach-drop { border: 2px dashed var(--border-light); border-radius: 10px; padding: 1.25rem; text-align: center; cursor: pointer; transition: border-color .15s, background .15s; color: var(--text-muted); font-size: .875rem; }
.attach-drop:hover,.attach-drop.drag-over { border-color: var(--primary); background: #eff6ff; color: var(--primary); }
.attach-preview-list { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
.attach-preview-item { position: relative; }
.attach-preview-item img { width: 64px; height: 64px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border-light); }
.attach-preview-item .rm { position: absolute; top: -6px; right: -6px; background: var(--danger); color: #fff; border: none; border-radius: 50%; width: 18px; height: 18px; font-size: .65rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.url-row { display: flex; gap: .5rem; }
.url-row input:first-child { flex: 1.5; }
.url-row input:last-child { flex: 1; }
/* Tags autocomplete */
.tags-suggestions { position: absolute; z-index: 100; background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.1); max-height: 160px; overflow-y: auto; min-width: 160px; }
.tags-suggestions div { padding: .4rem .75rem; cursor: pointer; font-size: .82rem; color: var(--text-main); }
.tags-suggestions div:hover { background: var(--bg-page); }
/* Lightbox */
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.88); z-index: 10000; display: none; align-items: center; justify-content: center; cursor: zoom-out; }
.lightbox.show { display: flex; }
.lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 8px; object-fit: contain; }
/* Empty state */
.memo-empty { text-align: center; padding: 4rem 2rem; color: var(--text-muted); }
.memo-empty .icon { font-size: 3rem; opacity: .3; margin-bottom: 1rem; }
/* Btns */
.btn { display: inline-flex; align-items: center; gap: .4rem; padding: .45rem .9rem; border-radius: 8px; font-size: .875rem; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: all .15s; white-space: nowrap; }
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary { background: var(--bg-panel); color: var(--text-main); border-color: var(--border-light); }
.btn-secondary:hover { background: var(--bg-page); }
.btn-danger { background: #fff; color: var(--danger); border-color: #fecaca; }
.btn-danger:hover { background: #fef2f2; }
.btn-sm { padding: .28rem .65rem; font-size: .8rem; }
/* Toast */
.memo-toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; display: flex; flex-direction: column; gap: .5rem; }
.memo-toast { background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 10px; padding: .7rem 1rem; font-size: .875rem; min-width: 200px; color: var(--text-main); box-shadow: 0 4px 16px rgba(0,0,0,.12); border-left: 4px solid var(--primary); animation: toastIn .2s ease; }
.memo-toast.error { border-left-color: var(--danger); }
@keyframes toastIn { from { opacity:0;transform:translateX(16px); } to { opacity:1;transform:none; } }
/* Responsive */
@media (max-width: 768px) {
.memo-layout { grid-template-columns: 1fr; height: auto; }
.memo-sidebar { display: none; }
.memo-sidebar.open { display: flex; position: fixed; inset: 0; z-index: 200; width: 260px; height: 100vh; box-shadow: 4px 0 20px rgba(0,0,0,.2); }
.edit-split { grid-template-columns: 1fr; }
.notes-grid { grid-template-columns: 1fr; padding: 1rem; }
.memo-view-header { padding: 1rem; }
.memo-content-area { margin: .75rem; }
.memo-attachments { padding: 0 .75rem .75rem; }
.memo-edit-area { padding: .75rem; }
}
/* Dark mode */
[data-theme="dark"] .memo-sidebar { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .memo-sidebar-top { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .memo-search { background: var(--bg-page); border-color: var(--border-light); }
[data-theme="dark"] .memo-search input { color: var(--text-main); }
[data-theme="dark"] .memo-tag-item { color: var(--text-muted); }
[data-theme="dark"] .memo-tag-item:hover { background: var(--bg-page); }
[data-theme="dark"] .memo-tag-item.active { background: rgba(59,130,246,.15); color: var(--primary); }
[data-theme="dark"] .memo-main { background: var(--bg-page); }
[data-theme="dark"] .memo-list-header { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .note-card { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .note-card:hover { border-color: var(--primary); }
[data-theme="dark"] .note-card-title { color: var(--text-main); }
[data-theme="dark"] .memo-view-header { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .memo-content-area { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .md-body code { background: #1c2128; color: #f87171; }
[data-theme="dark"] .md-body blockquote { background: rgba(59,130,246,.08); }
[data-theme="dark"] .md-body table th { background: #1c2128; border-color: var(--border-light); }
[data-theme="dark"] .md-body table td { border-color: var(--border-light); }
[data-theme="dark"] .att-file-item,.att-url-item { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .memo-edit-form { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .form-control { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
[data-theme="dark"] .tags-input-wrap { background: #1c2128; border-color: var(--border-light); }
[data-theme="dark"] .md-preview-box { background: #1c2128; border-color: var(--border-light); }
[data-theme="dark"] .attach-drop { border-color: var(--border-light); }
[data-theme="dark"] .attach-drop:hover,.attach-drop.drag-over { background: rgba(59,130,246,.1); }
[data-theme="dark"] .btn-secondary { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .btn-secondary:hover { background: rgba(255,255,255,.06); }
[data-theme="dark"] .tags-suggestions { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .tags-suggestions div:hover { background: var(--bg-page); }
[data-theme="dark"] .memo-toast { background: var(--bg-panel); border-color: var(--border-light); color: var(--text-main); }
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more