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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:05:59 +02:00
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
113 changed files with 55471 additions and 5961 deletions
-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:
push:
branches: [main]
branches: [main, staging-perco, staging-fefe]
jobs:
deploy:
@@ -15,6 +15,21 @@ jobs:
username: perco
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/container/househub
git pull
echo "✓ HouseHub mis à jour"
BRANCH="${{ github.ref_name }}"
case "$BRANCH" in
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
.env
data/.hh_app_secret
extract_source.php
.htaccess
+132 -57
View File
@@ -1,5 +1,10 @@
🦙 HouseHub OS
Système de gestion familiale sur-mesure : Budget, Calendrier, Voyages, Cadeaux et Garage.
# 🦙 HouseHub OS
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)
@@ -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.
---
## 👥 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 |
|-----|-------------|
| `/register.php` | Créer un compte + nouvel espace, ou rejoindre un espace existant via code |
| `/login.php` | Connexion |
| `/settings.php` | Paramètres : profil, mot de passe, langue, modules actifs, code d'invitation |
| `/admin/` | Panneau d'administration (admin uniquement) |
| `/garage.php` | Module Garage — véhicules, entretiens, pièces |
| URL | Description |
| --------------- | ------------------------------------------------------------------- |
| `/register.php` | Créer un compte + nouvel espace, ou rejoindre via code d'invitation |
| `/login.php` | Connexion |
| `/settings.php` | Profil, mot de passe, langue, modules actifs, code d'invitation |
| `/admin/` | Panneau d'administration (admin uniquement) |
### Inviter quelqu'un dans son espace
1. Aller sur `/settings.php` → copier le code d'invitation
2. La personne va sur `/register.php` → onglet "Rejoindre" → coller le code
3. Les deux comptes partagent le même environnement de données
### Rôle admin
Le premier utilisateur inscrit doit être promu admin manuellement :
```sql
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
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).
## 🧩 Modules
🛠️ Fonctionnalités Principales
💰 Module Budget
Suivi Mensuel : Interface de gestion des dépenses réelles avec import CSV bancaire et catégorisation automatique.
Tous les modules sont activables/désactivables par famille depuis `/settings.php`.
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
Planning Hebdomadaire : Gestion visuelle des modes de garde, des congés enfants et de la maladie.
## 📋 Planka
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
[ ] Audit SQL : Remplacement des requêtes en boucle par des jointures.
## ✅ Todo — Rappels Discord
[ ] 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.
* **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.
Le cron tourne chaque minute et envoie une notification Discord à l'heure exacte configurée sur chaque tâche.
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>
<tbody>
<?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;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>
@@ -135,7 +135,7 @@ require __DIR__ . '/../header.php';
</thead>
<tbody>
<?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">
<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_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')
$tab = $_GET['tab'] ?? 'suivi';
@@ -15,8 +27,17 @@ require __DIR__ . '/header.php';
?>
<div class="pf-container">
<div class="pf-hero" style="text-align: center; margin-bottom: 30px;">
<h1 style="margin-bottom: 20px;"><?= tr('budget_main_header') ?></h1>
<div class="pf-hero budget-hero">
<div class="budget-title-group">
<h1><?= tr('budget_main_header') ?></h1>
<!-- Bouton avec séparation claire entre l'engrenage animable et l'alerte fixe -->
<button class="btn-header-settings" onclick="openBudgetSettings()" title="<?= tr('settings') ?>">
<span class="gear-icon-anim">⚙️</span>
<?php if (!$isBudgetSetupOk || !$hasBudgetItems): ?>
<span class="alert-dot">!</span>
<?php endif; ?>
</button>
</div>
<nav class="budget-tabs-container">
<a href="?tab=suivi" class="tab-item <?= $tab == 'suivi' ? 'active' : '' ?>">
@@ -38,13 +59,18 @@ require __DIR__ . '/header.php';
<span class="tab-icon">📊</span>
<span><?= tr('budget_tab_recap') ?></span>
</a>
<a href="?tab=provisions" class="tab-item <?= $tab == 'provisions' ? 'active' : '' ?>">
<span class="tab-icon">🧮</span>
<span><?= tr('budget_tab_provisions') ?></span>
</a>
</nav>
</div>
<section class="pf-section">
<?php
// ROUTAGE DYNAMIQUE
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev'];
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev', 'provisions'];
if (in_array($tab, $allowedTabs)) {
$viewPath = __DIR__ . "/modules/budget/views/" . $tab . ".php";
@@ -52,7 +78,7 @@ require __DIR__ . '/header.php';
if (file_exists($viewPath)) {
include $viewPath;
} 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 "<p>" . sprintf(tr('budget_err_file_detail'), htmlspecialchars($tab)) . "</p>";
echo "</div>";
@@ -63,26 +89,8 @@ require __DIR__ . '/header.php';
?>
</section>
</div>
<script>
// --- AUTO-SCROLL DES ONGLETS MOBILES ---
document.addEventListener('DOMContentLoaded', () => {
const tabsContainer = document.querySelector('.budget-tabs-container');
const activeTab = document.querySelector('.tab-item.active');
// Si on a bien un conteneur d'onglets ET un onglet actif
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'; ?>
<?php
require __DIR__ . '/modules/budget/views/settings_modal.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 lagenda. 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);
}
}
-6
View File
@@ -4,12 +4,6 @@
════════════════════════════════════════════════════════════════════ */
/* ─── HOME PAGE ─────────────────────────────────────────────────────────────── */
/* Le header home.css a plus de spécificité que global.css → on le force ici */
[data-theme="dark"] body[data-page="home"] .pf-header {
background: rgba(13,17,23,.92) !important;
border-color: rgba(48,54,61,.6) !important;
backdrop-filter: blur(8px) !important;
}
[data-theme="dark"] body[data-page="home"] .pf-module-card {
background: rgba(22,27,34,.92) !important;
border: 1px solid var(--border-light) !important;
+2
View File
@@ -12,6 +12,8 @@ services:
DB_NAME: ${DB_NAME:-househub}
DB_USER: ${DB_USER:-househub}
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:
- househub_uploads:/uploads
- /opt/container/househub:/var/www/html
+18 -1
View File
@@ -26,6 +26,23 @@ CREATE TABLE IF NOT EXISTS users (
FOREIGN KEY (family_id) REFERENCES families(id)
) 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
GRANT ALL PRIVILEGES ON `househub_f%`.* TO 'househub'@'%';
FLUSH PRIVILEGES;
FLUSH PRIVILEGES;
+5 -3
View File
@@ -1,3 +1,5 @@
upload_max_filesize = 20M
post_max_size = 25M
memory_limit = 256M
upload_max_filesize = 300M
post_max_size = 310M
memory_limit = 512M
max_execution_time = 120
max_input_time = 120
+441 -76
View File
@@ -1,10 +1,43 @@
-- HouseHub — Schéma MySQL
-- Créer la base : CREATE DATABASE househub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- HouseHub — Schéma MySQL (Modèle pour une base famille)
SET NAMES utf8mb4;
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 (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
@@ -13,9 +46,15 @@ CREATE TABLE IF NOT EXISTS pf_users (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Personnes ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_people (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL
CREATE TABLE IF NOT EXISTS `pf_people` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`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;
-- ─── Calendrier familial ──────────────────────────────────────────────────────
@@ -53,10 +92,29 @@ CREATE TABLE IF NOT EXISTS pf_leave_snapshots (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_person_leave_meta (
id INT AUTO_INCREMENT PRIMARY KEY,
person_id INT NOT NULL UNIQUE,
anniversary_date DATE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
id INT(11) AUTO_INCREMENT PRIMARY KEY,
person_id INT(11) NOT NULL,
leave_type VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL,
method VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'FIXED',
allowance DECIMAL(5,2) DEFAULT 0.00,
anniversary_date DATE DEFAULT NULL,
INDEX (person_id),
INDEX (leave_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ─── Types de Congés (Référentiel) ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_leave_types (
id INT(11) AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) COLLATE utf8mb4_general_ci NOT NULL,
label VARCHAR(100) COLLATE utf8mb4_general_ci NOT NULL,
default_allowance DECIMAL(5,2) DEFAULT 0.00,
reset_month INT(11) DEFAULT 1,
allow_carry_over TINYINT(1) DEFAULT 0,
UNIQUE KEY code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT IGNORE INTO pf_leave_types (code, label, default_allowance, reset_month, allow_carry_over) VALUES
('CP', 'Congés Payés', 25.00, 6, 0);
CREATE TABLE IF NOT EXISTS pf_calendar_weeks (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -73,83 +131,141 @@ CREATE TABLE IF NOT EXISTS pf_calendar_weeks (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── 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 (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
amount DECIMAL(10,2) DEFAULT 0,
category VARCHAR(100),
type VARCHAR(50),
payment_day INT DEFAULT NULL,
is_estimate TINYINT(1) DEFAULT 0,
reg_month VARCHAR(7) DEFAULT NULL,
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
amount DECIMAL(10,2) DEFAULT 0,
category VARCHAR(100),
type VARCHAR(50),
payment_day INT DEFAULT NULL,
is_estimate TINYINT(1) DEFAULT 0,
reg_month VARCHAR(7) DEFAULT NULL,
mapping_keywords TEXT DEFAULT NULL,
holiday_id INT DEFAULT NULL,
is_checked TINYINT(1) DEFAULT 0,
sort_order INT DEFAULT 0
holiday_id INT DEFAULT NULL,
is_checked TINYINT(1) DEFAULT 0,
sort_order INT DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_expenses (
id INT AUTO_INCREMENT PRIMARY KEY,
date_exp DATE NOT NULL,
gestion_month VARCHAR(7) NOT NULL,
category VARCHAR(100),
label VARCHAR(255),
amount DECIMAL(10,2) DEFAULT 0,
import_ref VARCHAR(255) DEFAULT NULL,
id INT AUTO_INCREMENT PRIMARY KEY,
date_exp DATE NOT NULL,
gestion_month VARCHAR(10) NOT NULL,
category VARCHAR(100),
label VARCHAR(255),
amount DECIMAL(10,2) DEFAULT 0,
import_ref VARCHAR(255) 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;
CREATE TABLE IF NOT EXISTS `pf_expected_expenses` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`title` VARCHAR(150) NOT NULL,
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`expected_date` DATE NOT NULL,
`is_paid` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_expected_date` (`expected_date`),
INDEX `idx_is_paid` (`is_paid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS pf_alloc_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
target DECIMAL(10,2) DEFAULT 0,
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
target DECIMAL(10,2) DEFAULT 0.00,
transfer_dest VARCHAR(50) DEFAULT NULL,
holiday_id INT DEFAULT NULL,
sort_order INT DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_alloc_values (
id INT AUTO_INCREMENT PRIMARY KEY,
month_date VARCHAR(7) NOT NULL,
cat_id INT NOT NULL,
amount_alex DECIMAL(10,2) DEFAULT 0,
amount_laia DECIMAL(10,2) DEFAULT 0,
UNIQUE KEY uq_alloc (month_date, cat_id)
id INT AUTO_INCREMENT PRIMARY KEY,
month_date VARCHAR(10) NOT NULL,
cat_id INT NOT NULL,
person_id INT NOT NULL,
amount DECIMAL(10,2) DEFAULT 0.00,
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;
CREATE TABLE IF NOT EXISTS pf_savings (
id INT AUTO_INCREMENT PRIMARY KEY,
owner VARCHAR(50) NOT NULL,
month_date VARCHAR(7) NOT NULL,
category VARCHAR(100) NOT NULL,
amount DECIMAL(10,2) DEFAULT 0,
holiday_id INT DEFAULT NULL
id INT AUTO_INCREMENT PRIMARY KEY,
owner VARCHAR(50) NOT NULL,
month_date VARCHAR(10) NOT NULL,
category VARCHAR(100) NOT NULL,
amount DECIMAL(10,2) DEFAULT 0,
holiday_id INT DEFAULT NULL,
UNIQUE KEY uq_savings (owner, month_date, category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
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,
amount DECIMAL(12,2) DEFAULT 0
amount DECIMAL(12,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_salary_config (
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
person VARCHAR(50) NOT NULL,
salary DECIMAL(10,2) DEFAULT 0,
mensualite DECIMAL(10,2) DEFAULT 0,
frais_func DECIMAL(10,2) DEFAULT 0,
eco_perso DECIMAL(10,2) DEFAULT 0,
eco_family DECIMAL(10,2) DEFAULT 0,
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
person VARCHAR(50) NOT NULL,
salary DECIMAL(10,2) DEFAULT 0,
mensualite DECIMAL(10,2) DEFAULT 0,
frais_func DECIMAL(10,2) DEFAULT 0,
eco_perso DECIMAL(10,2) DEFAULT 0,
eco_family DECIMAL(10,2) DEFAULT 0,
UNIQUE KEY uq_salary (year, person)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_import_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL
id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL,
budget_item_id INT(11) NULL DEFAULT NULL
) 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 (
id INT AUTO_INCREMENT PRIMARY KEY,
note_type VARCHAR(100) NOT NULL,
@@ -158,6 +274,29 @@ CREATE TABLE IF NOT EXISTS pf_notes (
UNIQUE KEY uq_note (note_type, reference_id)
) 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 ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_holidays (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -168,7 +307,33 @@ CREATE TABLE IF NOT EXISTS pf_holidays (
status VARCHAR(50) DEFAULT 'draft',
budget_food 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;
CREATE TABLE IF NOT EXISTS pf_holidays_items (
@@ -203,6 +368,17 @@ CREATE TABLE IF NOT EXISTS pf_holidays_ideas (
notes TEXT DEFAULT NULL
) 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 (
q_hash CHAR(64) PRIMARY KEY,
q VARCHAR(255),
@@ -214,27 +390,42 @@ CREATE TABLE IF NOT EXISTS pf_geocode_cache (
-- ─── Cadeaux ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_gifts (
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
adult_name VARCHAR(100) NOT NULL,
payer_name VARCHAR(100),
child_name VARCHAR(100) NOT NULL,
occasion VARCHAR(50) NOT NULL,
id INT(11) AUTO_INCREMENT PRIMARY KEY,
year INT(11) NOT NULL,
adult_name VARCHAR(100) NOT NULL,
payer_name VARCHAR(100) DEFAULT NULL,
child_name VARCHAR(100) NOT NULL,
occasion VARCHAR(50) NOT NULL,
gift_description TEXT NOT NULL,
product_link VARCHAR(500) DEFAULT NULL,
amount DECIMAL(8,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
product_link VARCHAR(500) DEFAULT NULL,
amount DECIMAL(8,2) DEFAULT 0.00
) 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 ────────────────────────────────────────────────────────
-- Utilisateur admin (mot de passe : changeme → à modifier !)
INSERT IGNORE INTO pf_users (id, username, password_hash, display_name)
VALUES (1, 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin');
INSERT IGNORE INTO pf_gift_occasions (code, name, icon, month_date, is_active, sort_order) VALUES
('XMAS', 'Noël', '🎄', '12-25', 1, 1),
('BIRTHDAY', 'Anniversaire', '🎂', NULL, 1, 2),
('OTHER', 'Autre', '🎁', NULL, 1, 99);
-- Personnes (IDs fixes correspondant aux constantes dans config.php)
INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex');
INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia');
-- Table des règles (Qui offre à Qui)
CREATE TABLE IF NOT EXISTS pf_gift_rules (
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 ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_vehicles (
@@ -246,6 +437,7 @@ CREATE TABLE IF NOT EXISTS pf_vehicles (
license_plate VARCHAR(50) DEFAULT NULL,
vin VARCHAR(100) DEFAULT NULL,
fuel_type VARCHAR(50) DEFAULT 'Essence',
consumption decimal(4,2) DEFAULT NULL COMMENT 'Consommation L/100km',
color VARCHAR(50) DEFAULT NULL,
purchase_date DATE DEFAULT NULL,
purchase_price DECIMAL(10,2) DEFAULT NULL,
@@ -293,3 +485,176 @@ CREATE TABLE IF NOT EXISTS pf_parts (
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_garage_documents (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NOT NULL,
maintenance_id INT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) DEFAULT NULL,
mime VARCHAR(120) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_gd_vehicle (vehicle_id),
KEY idx_gd_maint (maintenance_id),
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Todo ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_todo_lists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
color VARCHAR(20) DEFAULT '#3b82f6',
icon VARCHAR(10) DEFAULT '📋',
position INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_todos (
id INT AUTO_INCREMENT PRIMARY KEY,
list_id INT DEFAULT NULL,
title VARCHAR(500) NOT NULL,
notes TEXT DEFAULT NULL,
due_date DATE DEFAULT NULL,
due_time TIME DEFAULT NULL,
notified TINYINT(1) DEFAULT 0,
notified_date DATE DEFAULT NULL,
priority ENUM('none','low','medium','high') DEFAULT 'none',
done TINYINT(1) DEFAULT 0,
done_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Module Liste (Courses partagées) ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
color VARCHAR(20) DEFAULT NULL,
list_type VARCHAR(50) DEFAULT NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY,
list_id INT NOT NULL DEFAULT 1,
category_id INT DEFAULT NULL,
label VARCHAR(500) NOT NULL,
in_cart TINYINT(1) NOT NULL DEFAULT 0,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_items_list (list_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_grocery_history (
id INT AUTO_INCREMENT PRIMARY KEY,
label_hash CHAR(64) NOT NULL,
label_display VARCHAR(500) NOT NULL,
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_grocery_hist_hash (label_hash),
KEY idx_hist_last (last_used_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_list_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
icon VARCHAR(10) NOT NULL DEFAULT '🏷️',
name VARCHAR(100) NOT NULL,
position INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_item_category_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL,
category_id INT NOT NULL,
UNIQUE KEY uq_keyword (keyword)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Calendar iOS ─────────────────────────────────────────────────────────────
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;
+363 -139
View File
@@ -8,19 +8,96 @@ require_login();
require __DIR__ . '/includes/db.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');
$activePage = "family-calendar";
$mainClass = "pf-family-calendar";
$pageCss = "/modules/family-calendar/family-calendar.css";
$pageCss = "/modules/family-calendar/family-calendar.css";
require __DIR__ . '/header.php';
?>
<div class="pf-container" style="max-width:100%; padding:0;">
<div class="fc-header-row">
<h1><?= tr('fc_main_header') ?></h1>
<div style="display: flex; gap: 10px;">
<div class="fc-header-row" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; margin-bottom: 24px;">
<!-- Bloc Titre + Roue crantée -->
<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">
<span class="icon">⚖️</span>
<span class="text"><?= tr('fc_btn_correct_balances') ?></span>
@@ -32,11 +109,28 @@ require __DIR__ . '/header.php';
</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 class="pf-modal-content">
<div class="pf-modal-header">
<h3 class="pf-modal-title"><?= tr('fc_modal_holidays_title') ?></h3>
<button id="btnCloseHolidays" class="pf-modal-close" onclick="document.getElementById('modalHolidays').classList.remove('open'); document.body.classList.remove('no-scroll');">&times;</button>
<h3 class="pf-modal-title">
<?= 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 class="pf-modal-body" style="padding:0;">
<div style="width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch;">
@@ -67,16 +161,20 @@ require __DIR__ . '/header.php';
<div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_person') ?></label>
<select id="snapPerson" class="pf-input" required>
<option value="2">Alex</option>
<option value="3">Laia</option>
<option value="" disabled selected>-- <?= tr('fc_choose_person') ?> --</option>
<?php foreach ($parents as $person): ?>
<option value="<?= htmlspecialchars($person['id']) ?>">
<?= htmlspecialchars($person['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_leave_type') ?></label>
<select id="snapType" class="pf-input" required>
<option value="CP">CP</option>
<option value="JRA">JRA</option>
<option value="JA">JA</option>
<?php foreach ($allLeaveTypes as $type): ?>
<option value="<?= htmlspecialchars($type) ?>"><?= htmlspecialchars($type) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
@@ -101,158 +199,284 @@ require __DIR__ . '/header.php';
</div>
</div>
</div>
</div>
<section class="pf-section">
<div class="fc-month-calendar-wrapper">
<div id="modalCalendarSettings" class="pf-modal">
<div class="pf-modal-content" style="max-width: 650px; width: 95%;">
<div class="fc-month-header">
<div class="fc-month-nav-row">
<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>
<select id="fc-select-year" class="fc-smart-select"></select>
<span id="fc-multi-month-suffix" style="display:none; font-size:1.3rem; font-weight:800; color:#0f172a; margin-left:4px;"></span>
<div class="pf-modal-header">
<h3 class="pf-modal-title">⚙️ <?= tr('fc_modal_settings_title') ?></h3>
<button type="button" class="pf-modal-close" onclick="closeCalendarSettings()">&times;</button>
</div>
<div style="display: flex; gap: 4px; background: var(--bg-page); padding: 4px; border-radius: 8px; margin: 1rem;">
<button type="button" id="tab-btn-foyer" class="bs-tab-btn active" style="flex: 1; padding: 8px 12px; font-weight: 600;" onclick="switchCalendarTab('foyer')">
🏡 <?= tr('fc_tab_foyer') ?>
</button>
<button type="button" id="tab-btn-membres" class="bs-tab-btn" style="flex: 1; padding: 8px 12px; font-weight: 600;" onclick="switchCalendarTab('membres')">
👥 <?= tr('fc_tab_members') ?>
</button>
</div>
<div class="pf-modal-body" style="max-height: 65vh; overflow-y: auto; padding-top: 0; padding-bottom: 1rem;">
<div id="cal-pane-foyer" class="cal-settings-pane">
<form id="formCalFoyer" onsubmit="submitCalFoyer(event)">
<div class="pf-form-group">
<label class="pf-label"><?= tr('fc_zone_label') ?></label>
<select id="setZoneScolaire" name="zone_scolaire" class="pf-input" required style="width: 100%;">
<option value="A"><?= tr('fc_zone_a') ?></option>
<option value="B"><?= tr('fc_zone_b') ?></option>
<option value="C"><?= tr('fc_zone_c') ?></option>
<option value="Autre"><?= tr('fc_zone_other') ?></option>
</select>
<small class="pf-muted-note"><?= tr('fc_zone_desc') ?></small>
</div>
<div class="pf-form-group" style="margin-top: 1.5rem;">
<label class="pf-label"><?= tr('fc_care_modes_label') ?></label>
<?php if (!empty($kids)): ?>
<div id="careModesContainer" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; min-height: 38px; padding: 8px; background: var(--bg-page); border-radius: var(--radius); border: 1px solid var(--border-light);"></div>
<div style="display: flex; gap: 8px;">
<input type="text" id="inputNewCareMode" class="pf-input" placeholder="<?= tr('fc_add_mode_placeholder') ?>" style="flex: 1;">
<button type="button" class="pf-btn btn-secondary" style="padding: 0 16px; border-radius: 6px;" onclick="addCareModeTag()"></button>
</div>
<small class="pf-muted-note"><?= tr('fc_care_modes_desc') ?></small>
<?php else: ?>
<div style="background: var(--bg-subtle); padding: 12px; border-radius: 8px; border: 1px dashed var(--border-main); text-align: center;">
<p style="margin: 0 0 10px 0; color: var(--text-muted); font-size: 0.9rem;">
<?= tr('fc_empty_kids_msg') ?>
</p>
<a href="/settings.php" class="pf-btn btn-secondary" style="text-decoration: none; display: inline-block; font-size: 0.85rem;">
⚙️ <?= tr('fc_link_settings') ?>
</a>
</div>
<?php endif; ?>
</div>
<?php if (!empty($kids)): ?>
<div style="margin-top: 1rem; display: flex; justify-content: flex-end;">
<button type="submit" class="pf-btn pf-btn-primary"><?= tr('save') ?></button>
</div>
<?php endif; ?>
</form>
<div class="pf-form-group" style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border-light);">
<h4 style="margin: 0 0 10px 0; color: var(--text-main);">🏖️ <?= tr('fc_leave_catalog_title') ?></h4>
<p style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 15px;">
<?= tr('fc_leave_catalog_desc') ?>
</p>
<div id="leaveTypesContainer" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px;"></div>
<div style="background: var(--bg-subtle); padding: 12px; border-radius: 8px; border: 1px solid var(--border-light);">
<h5 id="leaveTypeFormTitle" style="margin: 0 0 10px 0;">+ <?= tr('fc_add_leave_type') ?></h5>
<form id="formLeaveType" style="display: flex; flex-direction: column; gap: 10px;">
<input type="hidden" id="lt-mode" value="add">
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<div style="flex: 0 0 80px;">
<label class="pf-label"><?= tr('fc_col_code') ?> *</label>
<input type="text" id="lt-code" class="pf-input" maxlength="4" style="text-transform: uppercase;" required>
<small class="pf-muted-note" id="lt-code-note"><?= tr('fc_code_irreversible') ?></small>
</div>
<div style="flex: 1; min-width: 150px;">
<label class="pf-label"><?= tr('fc_col_label') ?> *</label>
<input type="text" id="lt-label" class="pf-input" placeholder="<?= tr('fc_leave_label_ph') ?>" required>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 5px;">
<button type="button" class="pf-btn btn-secondary" onclick="resetLeaveTypeForm()"><?= tr('btn_cancel') ?></button>
<button type="button" class="pf-btn pf-btn-primary" onclick="saveLeaveType()"><?= tr('btn_save') ?></button>
</div>
</form>
</div>
</div>
</div>
<button id="fc-next-month" class="fc-nav-button"></button>
</div>
<div class="fc-view-controls">
<button id="fc-today-btn" class="fc-today-button" title="<?= tr('fc_today_title') ?>"><?= tr('fc_today_short') ?></button> <div class="fc-view-divider"></div>
<button class="fc-view-button fc-view-button--active" data-view="1month"><?= tr('fc_view_1m') ?></button>
<button class="fc-view-button" data-view="2months"><?= tr('fc_view_2m') ?></button>
<button class="fc-view-button" data-view="3months"><?= tr('fc_view_3m') ?></button>
</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 class="fc-month-calendar-wrapper">
<div class="fc-month-header">
<div class="fc-month-nav-row">
<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>
<select id="fc-select-year" class="fc-smart-select"></select>
<span id="fc-multi-month-suffix" style="display:none; font-size:1.3rem; font-weight:800; color:#0f172a; margin-left:4px;"></span>
</div>
<button id="fc-next-month" class="fc-nav-button"></button>
</div>
<div class="fc-view-controls">
<button id="fc-today-btn" class="fc-today-button" title="<?= tr('fc_today_title') ?>"><?= tr('fc_today_short') ?></button>
<div class="fc-view-divider"></div>
<button class="fc-view-button fc-view-button--active" data-view="1month"><?= tr('fc_view_1m') ?></button>
<button class="fc-view-button" data-view="2months"><?= tr('fc_view_2m') ?></button>
<button class="fc-view-button" data-view="3months"><?= tr('fc_view_3m') ?></button>
</div>
</div>
<div class="fc-calendar-container">
<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-calendar" class="fc-month-calendar"></div>
<div id="fc-month-selectionMenu" class="fc-selection-menu" style="display:none;"></div>
</div>
<div id="fc-month-balances" class="fc-month-balances"></div>
</div>
</section>
</div>
<section class="pf-section">
<div class="fc-week-header">
<h2><?= tr('fc_weekly_planning') ?></h2>
<div class="fc-week-nav-controls">
<button id="fc-prev-school-year" class="fc-nav-button"></button>
<span id="fc-current-school-year-label"></span>
<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>
<section class="pf-section" style="margin-top: 1.5rem;">
<div class="fc-week-header">
<h2><?= tr('fc_weekly_planning') ?></h2>
<div class="fc-week-nav-controls">
<button id="fc-prev-school-year" class="fc-nav-button"></button>
<span id="fc-current-school-year-label"></span>
<button id="fc-next-school-year" class="fc-nav-button"></button>
</div>
</div>
<div class="pf-card">
<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>
<?php foreach ($activeCareModes as $mode): ?>
<th rowspan="3" class="col-total rotated-text"><span><?= htmlspecialchars($mode) ?></span></th>
<?php endforeach; ?>
<?php foreach ($kids as $kid): ?>
<th rowspan="3" class="col-total rotated-text"><span>Maladie <?= htmlspecialchars($kid['name']) ?></span></th>
<?php endforeach; ?>
<?php foreach ($parents as $index => $parent):
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia'; // Couleurs alternées
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
$colspan = count($pLeaves) * 2;
?>
<?php if ($colspan > 0): ?>
<th colspan="<?= $colspan ?>" class="<?= $parentClass ?> header-group"><?= htmlspecialchars(strtoupper($parent['name'])) ?></th>
<?php endif; ?>
<?php endforeach; ?>
</tr>
<tr>
<?php foreach ($parents as $index => $parent):
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
?>
<?php foreach ($pLeaves as $type): ?>
<th colspan="2" class="<?= $parentClass ?>-sub"><?= htmlspecialchars($type) ?></th>
<?php endforeach; ?>
<?php endforeach; ?>
</tr>
<tr>
<?php foreach ($parents as $index => $parent):
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
$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>
<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>
<div class="pf-card-body">
<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-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>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-centre"></div><span><?= tr('leg_centre') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-avis"></div><span><?= tr('leg_avis') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-pep-sick"></div><span><?= tr('leg_pep_sick') ?></span></div>
<!-- Intervenants (Dynamique) -->
<?php foreach ($helpers as $helper): ?>
<div class="pf-legend-item"><div class="pf-legend-color" style="background: var(--warning);"></div><span>Off <?= htmlspecialchars($helper['name']) ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color" style="background: var(--danger);"></div><span>Extra <?= htmlspecialchars($helper['name']) ?></span></div>
<?php endforeach; ?>
<!-- Modes de garde (Dynamique avec icônes) -->
<?php foreach ($activeCareModes as $index => $mode):
$modeLower = strtolower($mode);
if ($modeLower === 'avis'): ?>
<div class="pf-legend-item">
<img src="/modules/family-calendar/assets/img/avis.svg" style="width:16px;height:16px;object-fit:contain;margin-right:6px;" alt="Avis">
<span><?= htmlspecialchars($mode) ?></span>
</div>
<?php elseif ($modeLower === 'centre'): ?>
<div class="pf-legend-item">
<span style="font-size:1.1rem; line-height:1; margin-right:6px;">🏫</span>
<span><?= htmlspecialchars($mode) ?></span>
</div>
<?php else: ?>
<div class="pf-legend-item">
<div class="pf-legend-color" style="background: var(--primary);"></div>
<span><?= htmlspecialchars($mode) ?></span>
</div>
<?php endif;
endforeach; ?>
<!-- Enfants Malades (Dynamique avec couleur BDD) -->
<?php foreach ($kids as $kid):
// Fallback aligné exactement sur celui du JS (#e11d48)
$color = !empty($kid['color']) ? $kid['color'] : '#e11d48';
?>
<div class="pf-legend-item">
<div class="pf-legend-color" style="background: <?= htmlspecialchars($color) ?>; opacity: 0.8;"></div>
<span>Maladie <?= htmlspecialchars($kid['name']) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</section>
</div>
<script>
window.I18N = {
...(window.I18N || {}),
'fc_menu_carole': "<?= tr('fc_menu_carole') ?>",
'btn_off': "<?= tr('btn_off') ?>",
'btn_extra': "<?= tr('btn_extra') ?>",
'leg_centre': "<?= tr('leg_centre') ?>",
'leg_avis': "<?= tr('leg_avis') ?>",
'leg_pep_sick': "<?= tr('leg_pep_sick') ?>",
'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') ?>"
window.FAMILY_CONFIG = {
parents: <?= json_encode($parents) ?>,
kids: <?= json_encode($kids) ?>,
activeCareModes: <?= json_encode($activeCareModes) ?>,
i18n: {
settings_updated: <?= json_encode(tr('fc_settings_updated')) ?>,
empty_matrix: "Aucun compteur de congés défini pour le moment.",
btn_delete: <?= json_encode(tr('btn_delete') ?? 'Supprimer') ?>
}
};
</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'; ?>
+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 red"><div class="label">Coût total</div><div class="value" id="stat-cost">--</div></div>
</div>
<div style="display:grid;grid-template-columns:1fr 380px;gap:1.5rem;align-items:start">
<div>
<div class="card-header" style="margin-bottom:.75rem">
<div class="garage-dash-layout">
<div class="garage-dash-main">
<div class="card-header garage-card-header" style="margin-bottom:.75rem">
<h2 style="font-size:1rem;font-weight:600">Mes véhicules</h2>
<button class="btn btn-primary btn-sm" onclick="openAddVehicle()">+ Ajouter</button>
</div>
<div id="dashboard-vehicles" class="vehicles-grid"></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 id="reminders-list"></div>
</div>
@@ -47,7 +47,7 @@ require __DIR__ . '/header.php';
<!-- ── VÉHICULES ──────────────────────────────────────────────────────────── -->
<div id="page-vehicles" class="page">
<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>
<button class="btn btn-primary" onclick="openAddVehicle()">+ Ajouter un véhicule</button>
</div>
@@ -62,22 +62,38 @@ require __DIR__ . '/header.php';
<button class="btn btn-secondary btn-sm" onclick="navigate('vehicles')">← Retour</button>
</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" 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 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>
</div>
<div id="maintenance-list"></div>
</div>
<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>
</div>
<div id="parts-list-vehicle"></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>
@@ -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>
</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-header">
<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 ─────────────────────────────────────────────────── -->
<div id="page-maintenances-all" class="page">
<div class="container">
<div class="card-header" style="margin-bottom:1rem">
<div class="card-header garage-card-header" style="margin-bottom:1rem">
<div>
<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>
@@ -140,7 +178,7 @@ require __DIR__ . '/header.php';
<!-- ── TOUTES LES PIÈCES ──────────────────────────────────────────────────── -->
<div id="page-parts" class="page">
<div class="container">
<div class="card-header" style="margin-bottom:1rem">
<div class="card-header garage-card-header" style="margin-bottom:1rem">
<div>
<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>
@@ -179,15 +217,25 @@ require __DIR__ . '/header.php';
</select>
</div>
</div>
<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">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 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">Prix d'achat (€)</label><input class="form-control" id="vehicle-purchase-price" name="purchase_price" type="number" step="0.01"></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">
<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')">
+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(); }
// --- 1. CONFIGURATION ---
// --- 1. CONFIGURATION DE BASE ---
$year = (int)date('Y');
$pageTitle = tr('gift_page_title');
$activePage = "gift-list";
$mainClass = "pf-gift-list";
$pageCss = "/modules/gift-list/gift-list.css";
$children = ['Pol', 'Pep', 'Elna', 'Bru', 'Guim'];
$baseAdults = ['Laia', 'Laura', 'Avi Iaia'];
$extraAdults = ['Pauline', 'Papy JC', 'Mamy Caro'];
// --- 2. RÉCUPÉRATION DYNAMIQUE DES DONNÉES ---
// On inclut les enfants du foyer ET les proches enfants
$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 = [
'Pol' => array_merge($baseAdults, $extraAdults),
'Pep' => array_merge($baseAdults, $extraAdults),
'Elna' => $baseAdults,
'Bru' => $baseAdults,
'Guim' => $baseAdults,
];
// On inclut les adultes du foyer ET les proches adultes (On exclut nounou et les enfants)
$stmt = $pdo->query("SELECT name FROM pf_people WHERE role NOT IN ('enfant', 'proche_enfant', 'nounou') ORDER BY name ASC");
$allAdultsList = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: ['Aucun adulte configuré'];
$VIEWS = [
'nadal' => ['TIO', 'NOEL', 'ROIS'],
'anniversary' => ['ANNIV', 'SANT'],
];
// On récupère toutes les occasions (avec l'icône)
$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");
$activeOccasions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$currentView = strtolower($_GET['view'] ?? ($_SESSION['gift_view'] ?? 'nadal'));
if (!isset($VIEWS[$currentView])) $currentView = 'nadal';
$_SESSION['gift_view'] = $currentView;
$allowedOccasions = $VIEWS[$currentView];
if (empty($activeOccasions)) {
$activeOccasions = [['code' => 'DEMO', 'name' => 'Fête par défaut', 'icon' => '🎁', 'month_date' => null]];
}
$allowedOccasionCodes = array_column($activeOccasions, 'code');
$allOccasionLabels = [
'TIO' => tr('gift_occ_tio'), 'NOEL' => tr('gift_occ_noel'), 'ROIS' => tr('gift_occ_rois'),
'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
// --- 3. CHARGEMENT DES CADEAUX ---
$inMarks = implode(',', array_fill(0, count($allowedOccasionCodes), '?'));
$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->execute(array_merge([$year], $allowedOccasions));
$stmt->execute(array_merge([$year], $allowedOccasionCodes));
$gifts = $stmt->fetchAll(PDO::FETCH_ASSOC);
$data = [];
$data = [];
$adultsInView = [];
foreach ($gifts as $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'];
$adultsInView[$g['adult_name']] = true;
if (!empty($g['payer_name'])) {
$adultsInView[$g['payer_name']] = true;
}
}
$allAdultsList = array_keys($adultsInView); sort($allAdultsList);
// --- 3. TRICOUNT ---
$people = array_values(array_unique(array_merge($baseAdults, array_column($gifts, 'adult_name'), array_column($gifts, 'payer_name'))));
// --- 4. TRICOUNT (Bilan et Remboursements) ---
$people = array_values(array_unique(array_merge($allAdultsList, array_keys($adultsInView))));
$people = array_filter($people);
sort($people);
$matrix = [];
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++) {
$a = $people[$i]; $b = $people[$j];
$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]; }
}
}
@@ -94,26 +83,27 @@ for ($i = 0; $i < $countPeople; $i++) {
require __DIR__ . '/header.php';
?>
<div class="pf-container cl-view-<?= htmlspecialchars($currentView) ?>">
<div class="pf-container cl-view-dynamic">
<div class="cl-titlebar">
<h1>🎁 <?= sprintf(tr('gift_main_title'), $year) ?></h1>
<div class="cl-view-switch">
<a href="?view=nadal" class="cl-view-btn <?= $currentView === 'nadal' ? 'is-active' : '' ?>"><?= tr('gift_view_nadal') ?></a>
<a href="?view=anniversary" class="cl-view-btn <?= $currentView === 'anniversary' ? 'is-active' : '' ?>"><?= tr('gift_view_anniv') ?></a>
<div style="display: flex; align-items: center; gap: 12px;">
<h1 style="margin: 0;">🎁 <?= sprintf(tr('gift_main_title'), $year) ?></h1>
<button id="btn-open-gift-settings" class="btn-settings-gear" title="<?= tr('gift_settings') ?>" style="font-size: 1.4rem; padding: 4px;">
⚙️
</button>
</div>
</div>
<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-ms-trigger" onclick="toggleMS('ms-child-list', this)">
👦 <span id="ms-child-label"><?= tr('gift_filter_all_children') ?></span>
</div>
<div class="pf-ms-dropdown" id="ms-child-list">
<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') ?>
</label>
<?php foreach($children as $c): ?>
@@ -128,7 +118,7 @@ require __DIR__ . '/header.php';
</div>
<div class="pf-ms-dropdown" id="ms-adult-list">
<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') ?>
</label>
<?php foreach($allAdultsList as $a): ?>
@@ -139,24 +129,28 @@ require __DIR__ . '/header.php';
</div>
<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">
<h2 class="cl-occasion-title">
<?php if(!empty($occasionIcons[$occCode])): ?><img src="<?= $occasionIcons[$occCode] ?>" class="cl-occasion-icon"><?php endif; ?>
<?= $allOccasionLabels[$occCode] ?>
<span class="cl-occasion-icon"><?= htmlspecialchars($occIcon) ?></span>
<?= htmlspecialchars($occName) ?>
</h2>
<?php foreach ($children as $child):
<?php foreach ($children as $child):
$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-header">
<h3>👦 <?= htmlspecialchars($child) ?></h3>
<button class="pf-btn pf-btn-small btn-add-gift"
data-child="<?= htmlspecialchars($child) ?>"
data-occ="<?= htmlspecialchars($occCode) ?>"
data-adults="<?= htmlspecialchars(json_encode(array_values($adultsForThisChild))) ?>">
<button class="pf-btn pf-btn-small btn-add-gift"
data-child="<?= htmlspecialchars($child) ?>"
data-occ="<?= htmlspecialchars($occCode) ?>"
data-adults="<?= htmlspecialchars(json_encode(array_values($allAdultsList))) ?>">
<?= tr('gift_add_gift') ?>
</button>
</div>
@@ -168,15 +162,15 @@ require __DIR__ . '/header.php';
</div>
<?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: ?>
<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">
<?php foreach ($childData['gifts'] as $g): ?>
<div class="pf-gift-card-compact js-gift-card" data-adult="<?= htmlspecialchars($g['adult_name']) ?>">
<div>
<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; ?>
</h4>
<span class="pf-pill-adult">👤 <?= htmlspecialchars($g['adult_name']) ?></span>
@@ -201,41 +195,42 @@ require __DIR__ . '/header.php';
<?php endforeach; ?>
</section>
<section class="pf-section pf-section--panel" style="border:1px solid var(--border-light); margin-top:40px;">
<h2 style="margin-top:0; color:var(--text-main); font-size:1.3rem;">⚖️ <?= tr('gift_liquidations') ?? 'Bilan & Remboursements' ?></h2>
<section class="pf-section pf-section--panel gift-tricount-section">
<h2 class="gift-tricount-title">⚖️ <?= tr('gift_liquidations') ?></h2>
<?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: ?>
<ul class="pf-tricount-list">
<?php foreach ($settlements as $s): ?>
<li class="pf-tricount-item">
<span><strong><?= htmlspecialchars($s['from']) ?></strong> <?= tr('gift_owes') ?> à <?= htmlspecialchars($s['to']) ?></span>
<strong style="color:var(--danger);"><?= number_format($s['amount'], 2, ',', ' ') ?> €</strong>
<span><strong><?= htmlspecialchars($s['from']) ?></strong> <?= tr('gift_owes') ?> <?= tr('gift_to') ?> <?= htmlspecialchars($s['to']) ?></span>
<strong class="gift-tricount-debt-amount"><?= number_format($s['amount'], 2, ',', ' ') ?> €</strong>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<details style="margin-top:20px; padding:12px; border-radius:8px;">
<summary style="cursor:pointer; font-weight:600; color:var(--text-muted); outline:none;"><?= tr('gift_view_matrix') ?></summary>
<div style="overflow-x:auto; margin-top:15px;">
<details class="gift-matrix-details">
<summary class="gift-matrix-summary"><?= tr('gift_view_matrix') ?></summary>
<div class="gift-matrix-wrapper">
<table class="pf-table pf-table--compact cl-debt-matrix">
<thead>
<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; ?>
</tr>
</thead>
<tbody>
<?php foreach ($people as $debtor): ?>
<tr>
<th style="position:sticky; left:0; background:white; z-index:2; border-right:2px solid #e2e8f0;"><?= htmlspecialchars($debtor) ?></th>
<?php foreach ($people as $creditor):
<th class="sticky-col bg-body"><?= htmlspecialchars($debtor) ?></th>
<?php foreach ($people as $creditor):
$val = $matrix[$debtor][$creditor] ?? 0;
$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; ?>
</tr>
<?php endforeach; ?>
@@ -246,27 +241,28 @@ require __DIR__ . '/header.php';
</section>
</div>
<!-- 1. Modale CRUD Cadeau -->
<div id="pf-gift-modal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 500px; width: 95%;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 id="modalTitle" class="pf-modal-title" style="margin:0; border:none; padding:0;">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>
<div class="pf-modal-content gift-modal-custom-content">
<div class="gift-modal-custom-header">
<h3 id="modalTitle" class="pf-modal-title gift-modal-custom-title">Cadeau</h3>
<button type="button" class="btn-modal-close gift-modal-close-btn">&times;</button>
</div>
<form method="post" action="/modules/gift-list/save-gift.php" id="giftForm">
<input type="hidden" name="year" value="<?= $year ?>">
<input type="hidden" name="child_name" id="modalChild">
<input type="hidden" name="occasion" id="modalOccasion">
<input type="hidden" name="action" id="modalAction">
<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_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_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="modal-footer" style="padding-top:15px; display:flex; justify-content:flex-end; gap:10px;">
<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 gift-modal-custom-footer">
<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>
</div>
@@ -274,6 +270,91 @@ require __DIR__ . '/header.php';
</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>
// ==========================================
// 1. GESTION DU COMPOSANT MULTI-SELECT VANILLA
@@ -297,7 +378,7 @@ document.addEventListener('click', e => {
function handleMSChange(cb, type) {
const container = document.getElementById('ms-' + type + '-list');
if (!container) return;
if (cb.value === 'all' && cb.checked) {
container.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(i => i.checked = false);
} else if (cb.checked) {
@@ -310,10 +391,10 @@ function handleMSChange(cb, type) {
if (allCb) allCb.checked = true;
}
}
const checkedSpecifics = container.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])');
const labelEl = document.getElementById('ms-' + type + '-label');
if (labelEl) {
if (checkedSpecifics.length === 0) {
let defaultText = 'Tous';
@@ -335,23 +416,23 @@ function applyGiftFilters() {
const cList = document.getElementById('ms-child-list');
const aList = document.getElementById('ms-adult-list');
if (!cList || !aList) return;
const allChildCb = cList.querySelector('input[value="all"]');
const cAll = allChildCb ? allChildCb.checked : true;
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 aAll = allAdultCb ? allAdultCb.checked : true;
const aVals = Array.from(aList.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])')).map(i => i.value);
document.querySelectorAll('.js-occ-section').forEach(occSec => {
let occHasVisible = false;
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);
let childHasVisibleCard = false;
if (matchChild) {
childSec.querySelectorAll('.js-gift-card').forEach(card => {
const aName = card.getAttribute('data-adult');
@@ -359,36 +440,34 @@ function applyGiftFilters() {
card.style.display = matchAdult ? 'flex' : 'none';
if (matchAdult) childHasVisibleCard = true;
});
childSec.querySelectorAll('.js-pill-adult').forEach(pill => {
const aName = pill.getAttribute('data-adult');
const matchAdult = aAll || (aVals.indexOf(aName) !== -1);
pill.style.opacity = matchAdult ? '1' : '0.2';
});
const empty = childSec.querySelector('.js-empty-state');
if (empty) empty.style.display = childHasVisibleCard ? 'none' : 'block';
childSec.style.display = 'block';
occHasVisible = true;
} else {
childSec.style.display = '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 adultSelect = document.getElementById('modalAdult');
const payerSelect = document.getElementById('modalPayer');
// Synchronisation automatique : Adulte -> Payeur
if (adultSelect && payerSelect) {
adultSelect.addEventListener('change', function() {
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) {
// --- 1. BOUTON FERMER MODALE ---
if (e.target.closest('.btn-modal-close')) {
if (modal) {
modal.classList.remove('open');
@@ -421,23 +497,22 @@ document.body.addEventListener('click', async function(e) {
return;
}
// --- 2. BOUTON AJOUTER (+) ---
const btnAdd = e.target.closest('.btn-add-gift');
if (btnAdd) {
const childName = btnAdd.dataset.child;
const occCode = btnAdd.dataset.occ;
const adultsList = JSON.parse(btnAdd.dataset.adults || '[]');
document.getElementById('modalAction').value = 'create';
document.getElementById('modalAction').value = 'create';
document.getElementById('modalGiftId').value = '';
document.getElementById('modalChild').value = childName;
document.getElementById('modalChild').value = childName;
document.getElementById('modalOccasion').value = occCode;
document.getElementById('modalDesc').value = '';
document.getElementById('modalDesc').value = '';
document.getElementById('modalAmount').value = '';
document.getElementById('modalLink').value = '';
populateSelects(adultsList);
if (adultSelect && payerSelect) {
if (adultsList.indexOf('Laia') !== -1) {
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';
document.getElementById('modalTitle').textContent = titleStr.replace('%s', childName);
if (modal) { modal.classList.add('open'); document.body.classList.add('no-scroll'); }
return;
}
// --- 3. BOUTON MODIFIER (CRAYON) ---
const btnEdit = e.target.closest('.btn-edit-gift');
if (btnEdit) {
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('modalChild').value = data.child_name;
document.getElementById('modalChild').value = data.child_name;
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('modalLink').value = data.product_link;
@@ -471,31 +545,30 @@ document.body.addEventListener('click', async function(e) {
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<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 (!payerExists) payerSelect.appendChild(new Option(payerVal, payerVal));
adultSelect.value = data.adult_name;
payerSelect.value = payerVal;
}
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'); }
return;
}
// --- 4. BOUTON SUPPRIMER (POUBELLE) ---
const btnDel = e.target.closest('.btn-delete-gift');
if (btnDel) {
const giftId = btnDel.dataset.id;
const msg = window.I18N && window.I18N['gift_confirm_delete'] ? window.I18N['gift_confirm_delete'] : 'Supprimer ce cadeau ?';
if (!confirm(msg)) return;
const fd = new FormData();
fd.append('action', 'delete');
fd.append('gift_id', giftId);
try {
await fetch('/modules/gift-list/save-gift.php', { method: 'POST', body: fd });
window.location.reload();
@@ -506,17 +579,13 @@ document.body.addEventListener('click', async function(e) {
}
});
// ==========================================
// 4. SOUMISSION AJAX DU FORMULAIRE
// ==========================================
const giftForm = document.getElementById('giftForm');
if (giftForm) {
giftForm.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
const oldText = btn.innerText;
btn.innerText = '...';
btn.innerText = '...';
btn.disabled = true;
try {
@@ -524,11 +593,382 @@ if (giftForm) {
window.location.reload();
} catch(err) {
console.error(err);
btn.innerText = oldText;
btn.innerText = oldText;
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>
<?php require __DIR__ . '/footer.php'; ?>
+653 -91
View File
@@ -13,6 +13,15 @@
--text-muted: #64748b;
--bg-page: #f8fafc;
--bg-panel: #ffffff;
--bg-subtle: #f1f5f9;
--bg-soft: #eff6ff;
--bg-danger-soft: #fef2f2;
--bg-overlay: rgba(15, 23, 42, 0.6);
--text-on-primary: #ffffff;
--text-danger-soft: #f87171;
--border-strong: #cbd5e1;
--border-danger-soft: #fecaca;
--focus-ring: rgba(37, 99, 235, 0.1);
/* Bordures, Arrondis & Ombres */
--border-light: #e2e8f0;
@@ -20,6 +29,17 @@
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Alertes / badges (clair) — paramètres & blocs similaires */
--alert-success-bg: #dcfce7;
--alert-success-border: #86efac;
--alert-success-text: #166534;
--alert-error-bg: #fee2e2;
--alert-error-border: #fca5a5;
--alert-error-text: #991b1b;
--invite-code-color: #1d4ed8;
--badge-admin-bg: #dbeafe;
--badge-admin-text: #1d4ed8;
}
/* 🛡️ RESET MOBILE UNIVERSEL */
@@ -64,7 +84,7 @@ body.no-scroll {
/* === HEADER & NAVIGATION === */
.pf-header {
background: white;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-light);
padding: 0 20px;
height: 64px;
@@ -96,11 +116,11 @@ body.no-scroll {
transition: all 0.2s;
}
.pf-nav-link:hover {
background: #f1f5f9;
background: var(--bg-subtle);
color: var(--text-main);
}
.pf-nav-link--active {
background: #eff6ff;
background: var(--bg-soft);
color: var(--primary);
}
.pf-header-actions {
@@ -125,8 +145,8 @@ body.no-scroll {
border: 1px solid transparent;
}
.pf-logout-btn:hover {
background: #fef2f2;
border-color: #fecaca;
background: var(--bg-danger-soft);
border-color: var(--border-danger-soft);
}
.pf-burger-btn {
display: none;
@@ -142,7 +162,7 @@ body.no-scroll {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
background: var(--bg-overlay);
backdrop-filter: blur(4px);
z-index: 998;
opacity: 0;
@@ -216,12 +236,12 @@ body.no-scroll {
}
.pf-mobile-nav-link:active {
background: #f1f5f9;
background: var(--bg-subtle);
}
.pf-mobile-logout {
color: var(--danger);
background: #fef2f2;
background: var(--bg-danger-soft);
margin-top: 10px;
}
@@ -253,6 +273,313 @@ body.pf-home .pf-mobile-close-btn {
max-width: 1200px;
margin: 0 auto;
}
/* Bloc type « carte » (ex. paramètres) — suit le thème light/dark */
.pf-panel-card {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 24px;
}
/* ═══ Paramètres & petits blocs réutilisables ═══ */
.pf-settings-page {
max-width: 680px;
margin: 32px auto;
padding: 0 16px;
}
.pf-settings-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 32px;
color: var(--text-main);
}
.pf-card-h2 {
font-size: 1rem;
font-weight: 700;
margin-bottom: 20px;
color: var(--text-main);
}
.pf-card-h2--tight {
margin-bottom: 6px;
}
.pf-muted-note {
color: var(--text-muted);
font-size: 0.85rem;
}
.pf-muted-note.pf-muted-note--tight {
margin-bottom: 8px;
}
.pf-muted-tiny {
color: var(--text-muted);
font-size: 0.8rem;
}
.pf-muted-inline {
color: var(--text-muted);
font-size: 0.85rem;
margin-left: 6px;
}
.pf-alert {
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 24px;
}
.pf-alert--success {
background: var(--alert-success-bg);
border: 1px solid var(--alert-success-border);
color: var(--alert-success-text);
}
.pf-alert--error {
background: var(--alert-error-bg);
border: 1px solid var(--alert-error-border);
color: var(--alert-error-text);
}
.pf-lang-form {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.pf-btn--current-lang {
pointer-events: none;
}
.pf-stack-md {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.pf-stack-sm {
display: flex;
flex-direction: column;
gap: 8px;
}
/* --- SÉLECTEUR DE COULEUR THÉMATIQUE (SETTINGS) --- */
.pf-color-picker-group {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.pf-input-color {
/* On réduit la taille pour le rendre plus subtil */
width: 36px;
height: 36px;
/* On enlève les marges/paddings par défaut */
padding: 0;
cursor: pointer;
/* On le rend complètement rond, c'est plus moderne */
border-radius: 50%;
border: 2px solid #e2e8f0;
/* On cache le fond par défaut du navigateur */
background: transparent;
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
transition: all 0.2s ease;
/* Optionnel : masquer la bordure interne sur Chrome/Safari */
-webkit-appearance: none;
}
/* On s'assure que le carré de couleur interne (Webkit) remplit tout et est rond */
.pf-input-color::-webkit-color-swatch-wrapper {
padding: 0;
}
.pf-input-color::-webkit-color-swatch {
border: none;
border-radius: 50%;
}
/* Même chose pour Firefox */
.pf-input-color::-moz-color-swatch {
border: none;
border-radius: 50%;
}
.pf-input-color:hover {
border-color: #94a3b8;
transform: scale(1.05);
}
.pf-color-picker-group .pf-muted-note {
font-size: 0.85rem;
color: var(--text-muted, #64748b);
line-height: 1.4;
}
/* Tuile module (checkbox) — état coché via :has(), compatible thème */
.pf-module-tile {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 2px solid var(--border-light);
border-radius: 10px;
cursor: pointer;
background: var(--bg-page);
transition:
border-color 0.15s ease,
background 0.15s ease;
}
.pf-module-tile:has(input:checked) {
border-color: var(--primary);
background: var(--bg-soft);
}
.pf-checkbox-lg {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
flex-shrink: 0;
}
.pf-tile-icon {
font-size: 1.3rem;
line-height: 1;
}
.pf-tile-label {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-main);
}
/* Encadré pointillé (code dinvitation, etc.) */
.pf-dashed-panel {
background: var(--bg-page);
border: 2px dashed var(--border-strong);
border-radius: 10px;
padding: 20px;
}
.pf-home-bg-preview {
margin-bottom: 16px;
border-radius: 10px;
overflow: hidden;
max-height: 160px;
position: relative;
}
.pf-home-bg-preview img {
width: 100%;
height: 160px;
object-fit: cover;
display: block;
}
.pf-badge-overlay {
position: absolute;
top: 8px;
right: 8px;
background: var(--bg-overlay);
color: var(--text-on-primary);
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 6px;
}
.pf-inline-form-end {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.pf-inline-form-end .pf-form-group {
flex: 1;
min-width: 200px;
}
.pf-shrink-0 {
flex-shrink: 0;
}
.pf-mt-sm {
margin-top: 10px;
}
.pf-btn-sm-text {
font-size: 0.85rem;
}
.pf-mb-section {
margin-bottom: 20px;
}
.pf-invite-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pf-invite-code {
font-size: 1.3rem;
font-weight: 700;
letter-spacing: 3px;
color: var(--invite-code-color);
cursor: pointer;
}
.pf-copy-msg {
color: var(--success);
font-size: 0.85rem;
opacity: 0;
transition: opacity 0.3s;
}
.pf-copy-msg.is-visible {
opacity: 1;
}
.pf-ml-auto {
margin-left: auto;
}
.pf-invite-footnote {
margin-top: 12px;
font-size: 0.8rem;
color: var(--text-muted);
}
.pf-member-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--bg-page);
border-radius: 8px;
}
.pf-admin-badge {
background: var(--badge-admin-bg);
color: var(--badge-admin-text);
padding: 1px 7px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 6px;
}
.pf-members-heading {
font-size: 0.95rem;
font-weight: 600;
margin: 20px 0 12px;
color: var(--text-main);
}
.pf-settings-admin-link {
text-align: center;
margin-top: 8px;
}
.pf-settings-admin-link a {
color: var(--primary);
font-size: 0.9rem;
}
.pf-input--disabled-muted:disabled {
background: var(--bg-page);
color: var(--text-muted);
}
.pf-flag-inline {
vertical-align: middle;
border-radius: 2px;
margin-right: 5px;
}
.pf-flex-gap-8 {
display: flex;
gap: 8px;
}
h1 {
font-size: 2rem;
font-weight: 800;
@@ -274,7 +601,7 @@ p {
margin-top: 32px;
}
.pf-module-card {
background: white;
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 24px;
@@ -289,7 +616,7 @@ p {
.pf-module-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow);
border-color: #cbd5e1;
border-color: var(--border-strong);
}
.pf-card-icon {
font-size: 2rem;
@@ -322,7 +649,7 @@ p {
justify-content: center;
gap: 8px;
background: var(--primary);
color: white;
color: var(--text-on-primary);
border: none;
border-radius: 8px;
font-size: 1rem;
@@ -336,14 +663,14 @@ p {
background: var(--primary-dark);
}
.pf-btn.btn-secondary {
background: white;
background: var(--bg-panel);
color: var(--text-muted);
border: 1px solid var(--border-light);
}
.pf-btn.btn-secondary:hover {
background: #f8fafc;
background: var(--bg-page);
color: var(--text-main);
border-color: #94a3b8;
border-color: var(--border-strong);
}
/* Formulaires & Inputs Standards */
@@ -363,17 +690,17 @@ p {
border: 1px solid var(--border-light);
border-radius: 8px;
font-size: 1rem;
background: #f8fafc;
background: var(--bg-page);
color: var(--text-main);
transition: all 0.2s;
font-family: inherit;
box-sizing: border-box;
}
.pf-input:focus {
background: white;
background: var(--bg-panel);
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
box-shadow: 0 0 0 3px var(--focus-ring);
}
/* Tableaux Standards */
@@ -387,7 +714,7 @@ p {
overflow: hidden;
}
.pf-table th {
background: #f8fafc;
background: var(--bg-page);
padding: 16px;
text-align: left;
font-weight: 600;
@@ -407,7 +734,7 @@ p {
border-bottom: none;
}
.pf-table tbody tr:hover {
background-color: #f8fafc;
background-color: var(--bg-page);
}
/* === 🪟 LE DESIGN SYSTEM DES MODALES (Applicable partout) === */
@@ -416,7 +743,7 @@ p {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(15, 23, 42, 0.6);
background: var(--bg-overlay);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
@@ -480,7 +807,7 @@ p {
/* === FOOTER & TOASTS === */
.pf-footer {
border-top: 1px solid var(--border-light);
background: white;
background: var(--bg-panel);
padding: 24px;
text-align: center;
font-size: 0.85rem;
@@ -492,7 +819,7 @@ p {
bottom: 20px;
right: 20px;
padding: 12px 24px;
background: white;
background: var(--bg-panel);
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
border-left: 5px solid var(--primary);
@@ -548,7 +875,7 @@ p {
display: block;
width: 40px;
height: 5px;
background: #cbd5e1;
background: var(--border-strong);
border-radius: 10px;
margin: -10px auto 20px auto;
}
@@ -588,13 +915,13 @@ p {
color: var(--primary);
}
.btn-icon-action.edit:hover {
background: #eff6ff; /* Fond bleu très clair au survol */
background: var(--bg-soft); /* Fond bleu très clair au survol */
}
.btn-icon-action.delete {
color: var(--danger);
}
.btn-icon-action.delete:hover {
background: #fef2f2; /* Fond rouge très clair au survol */
background: var(--bg-danger-soft); /* Fond rouge très clair au survol */
}
/* === 13. PAGE DE CONNEXION (LOGIN) === */
@@ -610,7 +937,7 @@ p {
width: 100%;
max-width: 400px;
padding: 32px;
background: white;
background: var(--bg-panel);
border-radius: 16px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-light);
@@ -641,11 +968,11 @@ p {
}
.pf-login-error {
background: #fef2f2;
color: #ef4444;
background: var(--bg-danger-soft);
color: var(--danger);
padding: 12px;
border-radius: 8px;
border: 1px solid #fca5a5;
border: 1px solid var(--border-danger-soft);
margin-bottom: 20px;
font-size: 0.9rem;
text-align: center;
@@ -692,75 +1019,310 @@ p {
DARK MODE — [data-theme="dark"] sur <html>
══════════════════════════════════════════════════════════════ */
[data-theme="dark"] {
--text-main: #e6edf3;
--text-muted: #8b949e;
--bg-page: #0d1117;
--bg-panel: #161b22;
--text-main: #e6edf3;
--text-muted: #8b949e;
--bg-page: #0d1117;
--bg-panel: #161b22;
--border-light: #30363d;
--shadow: 0 4px 6px -1px rgba(0,0,0,.4), 0 2px 4px -2px rgba(0,0,0,.3);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,.5), 0 4px 6px -4px rgba(0,0,0,.4);
--shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--bg-subtle: rgba(255, 255, 255, 0.06);
--bg-soft: rgba(59, 130, 246, 0.15);
--bg-danger-soft: rgba(239, 68, 68, 0.1);
--bg-overlay: rgba(0, 0, 0, 0.55);
--text-danger-soft: #f87171;
--border-strong: #484f58;
--border-danger-soft: rgba(239, 68, 68, 0.3);
--focus-ring: rgba(59, 130, 246, 0.25);
--alert-success-bg: rgba(34, 197, 94, 0.12);
--alert-success-border: rgba(34, 197, 94, 0.45);
--alert-success-text: #86efac;
--alert-error-bg: rgba(239, 68, 68, 0.12);
--alert-error-border: rgba(248, 113, 113, 0.45);
--alert-error-text: #fca5a5;
--invite-code-color: #93c5fd;
--badge-admin-bg: rgba(59, 130, 246, 0.2);
--badge-admin-text: #93c5fd;
}
[data-theme="dark"] body {
background: var(--bg-page);
color: var(--text-main);
}
[data-theme="dark"] .pf-header {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-logo {
color: var(--primary);
}
[data-theme="dark"] .pf-nav-link {
color: var(--text-muted);
}
[data-theme="dark"] .pf-nav-link:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-main);
}
[data-theme="dark"] .pf-nav-link--active {
background: rgba(59, 130, 246, 0.15);
color: var(--primary);
}
[data-theme="dark"] .pf-user-badge {
color: var(--text-muted);
}
[data-theme="dark"] .pf-logout-btn {
color: #f87171;
}
[data-theme="dark"] .pf-logout-btn:hover {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
[data-theme="dark"] .pf-burger-btn {
color: var(--text-main);
}
[data-theme="dark"] .pf-mobile-menu {
background: var(--bg-panel);
color: var(--text-main);
}
[data-theme="dark"] .pf-mobile-menu-header {
background: var(--bg-page);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-mobile-nav-link {
color: var(--text-main);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-mobile-nav-link:active {
background: rgba(255, 255, 255, 0.06);
}
[data-theme="dark"] .pf-module-card {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-module-card:hover {
border-color: #484f58;
box-shadow: var(--shadow);
}
[data-theme="dark"] .pf-card-title {
color: var(--text-main);
}
[data-theme="dark"] .pf-card-desc {
color: var(--text-muted);
}
[data-theme="dark"] h1,
[data-theme="dark"] h2,
[data-theme="dark"] h3 {
color: var(--text-main);
}
[data-theme="dark"] p {
color: var(--text-muted);
}
[data-theme="dark"] .pf-btn.btn-secondary {
background: var(--bg-panel);
color: var(--text-muted);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-btn.btn-secondary:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
border-color: #484f58;
}
[data-theme="dark"] .pf-input {
background: #1c2128;
border-color: var(--border-light);
color: var(--text-main);
}
[data-theme="dark"] .pf-input::placeholder {
color: #484f58;
}
[data-theme="dark"] .pf-input:focus {
background: #1c2128;
border-color: var(--primary);
}
[data-theme="dark"] .pf-table {
background: var(--bg-panel);
}
[data-theme="dark"] .pf-table th {
background: #1c2128;
color: var(--text-muted);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-table td {
border-color: rgba(48, 54, 61, 0.5);
color: var(--text-main);
}
[data-theme="dark"] .pf-table tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
[data-theme="dark"] .pf-modal-content {
background: var(--bg-panel);
border: 1px solid var(--border-light);
}
[data-theme="dark"] .pf-modal-title {
border-color: var(--border-light);
color: var(--text-main);
}
[data-theme="dark"] .modal-footer {
border-color: var(--border-light);
}
[data-theme="dark"] .pf-footer {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-toast {
background: var(--bg-panel);
color: var(--text-main);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] .pf-login-card {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-login-error {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
[data-theme="dark"] .btn-icon-action.edit:hover {
background: rgba(59, 130, 246, 0.15);
}
[data-theme="dark"] .btn-icon-action.delete:hover {
background: rgba(239, 68, 68, 0.1);
}
[data-theme="dark"] body { background: var(--bg-page); color: var(--text-main); }
[data-theme="dark"] .pf-header { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .pf-logo { color: var(--primary); }
[data-theme="dark"] .pf-nav-link { color: var(--text-muted); }
[data-theme="dark"] .pf-nav-link:hover { background: rgba(255,255,255,.06); color: var(--text-main); }
[data-theme="dark"] .pf-nav-link--active { background: rgba(59,130,246,.15); color: var(--primary); }
[data-theme="dark"] .pf-user-badge { color: var(--text-muted); }
[data-theme="dark"] .pf-logout-btn { color: #f87171; }
[data-theme="dark"] .pf-logout-btn:hover { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.3); }
[data-theme="dark"] .pf-burger-btn { color: var(--text-main); }
[data-theme="dark"] .pf-mobile-menu { background: var(--bg-panel); color: var(--text-main); }
[data-theme="dark"] .pf-mobile-menu-header { background: var(--bg-page); border-color: var(--border-light); }
[data-theme="dark"] .pf-mobile-nav-link { color: var(--text-main); border-color: var(--border-light); }
[data-theme="dark"] .pf-mobile-nav-link:active { background: rgba(255,255,255,.06); }
[data-theme="dark"] .pf-module-card { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .pf-module-card:hover { border-color: #484f58; box-shadow: var(--shadow); }
[data-theme="dark"] .pf-card-title { color: var(--text-main); }
[data-theme="dark"] .pf-card-desc { color: var(--text-muted); }
[data-theme="dark"] h1, [data-theme="dark"] h2, [data-theme="dark"] h3 { color: var(--text-main); }
[data-theme="dark"] p { color: var(--text-muted); }
[data-theme="dark"] .pf-btn.btn-secondary { background: var(--bg-panel); color: var(--text-muted); border-color: var(--border-light); }
[data-theme="dark"] .pf-btn.btn-secondary:hover { background: rgba(255,255,255,.05); color: var(--text-main); border-color: #484f58; }
[data-theme="dark"] .pf-input { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
[data-theme="dark"] .pf-input::placeholder { color: #484f58; }
[data-theme="dark"] .pf-input:focus { background: #1c2128; border-color: var(--primary); }
[data-theme="dark"] .pf-table { background: var(--bg-panel); }
[data-theme="dark"] .pf-table th { background: #1c2128; color: var(--text-muted); border-color: var(--border-light); }
[data-theme="dark"] .pf-table td { border-color: rgba(48,54,61,.5); color: var(--text-main); }
[data-theme="dark"] .pf-table tbody tr:hover { background: rgba(255,255,255,.03); }
[data-theme="dark"] .pf-modal-content { background: var(--bg-panel); border: 1px solid var(--border-light); }
[data-theme="dark"] .pf-modal-title { border-color: var(--border-light); color: var(--text-main); }
[data-theme="dark"] .modal-footer { border-color: var(--border-light); }
[data-theme="dark"] .pf-footer { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .pf-toast { background: var(--bg-panel); color: var(--text-main); box-shadow: 0 10px 25px rgba(0,0,0,.4); }
[data-theme="dark"] .pf-login-card { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .pf-login-error { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.3); }
[data-theme="dark"] .btn-icon-action.edit:hover { background: rgba(59,130,246,.15); }
[data-theme="dark"] .btn-icon-action.delete:hover { background: rgba(239,68,68,.1); }
/* ─── Pill toggle thème ────────────────────────────────────── */
.theme-switch {
display: inline-flex; align-items: center; gap: 5px;
background: none; border: none; cursor: pointer;
padding: 3px 5px; border-radius: 8px; transition: background .15s;
display: inline-flex;
align-items: center;
gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 8px;
transition: background 0.15s;
}
.theme-switch:hover {
background: #f1f5f9;
}
[data-theme="dark"] .theme-switch:hover {
background: rgba(255, 255, 255, 0.08);
}
.ts-sun,
.ts-moon {
font-size: 0.85rem;
line-height: 1;
transition: opacity 0.2s;
user-select: none;
}
[data-theme="dark"] .ts-sun {
opacity: 0.3;
}
.ts-moon {
opacity: 0.3;
}
[data-theme="dark"] .ts-moon {
opacity: 1;
}
.theme-switch:hover { background: #f1f5f9; }
[data-theme="dark"] .theme-switch:hover { background: rgba(255,255,255,.08); }
.ts-sun, .ts-moon { font-size: .85rem; line-height: 1; transition: opacity .2s; user-select: none; }
[data-theme="dark"] .ts-sun { opacity: .3; }
.ts-moon { opacity: .3; }
[data-theme="dark"] .ts-moon { opacity: 1; }
.ts-track {
position: relative; width: 36px; height: 20px;
background: #cbd5e1; border-radius: 999px;
flex-shrink: 0; transition: background .25s;
position: relative;
width: 36px;
height: 20px;
background: #cbd5e1;
border-radius: 999px;
flex-shrink: 0;
transition: background 0.25s;
}
[data-theme="dark"] .ts-track {
background: #2563eb;
}
[data-theme="dark"] .ts-track { background: #2563eb; }
.ts-thumb {
position: absolute; top: 3px; left: 3px;
width: 14px; height: 14px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3);
transition: transform .25s cubic-bezier(.4,0,.2,1);
position: absolute;
top: 3px;
left: 3px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
[data-theme="dark"] .ts-thumb {
transform: translateX(16px);
}
/* === ACCORDÉONS NATIFS (DETAILS/SUMMARY) === */
.pf-accordion {
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
transition: all 0.3s ease;
}
.pf-accordion-summary {
padding: 14px 16px;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
cursor: pointer;
list-style: none; /* Masque la flèche native Firefox/Chrome */
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-page);
transition:
background 0.2s,
color 0.2s;
}
.pf-accordion-summary::-webkit-details-marker {
display: none; /* Masque la flèche native Safari */
}
.pf-accordion-summary:hover {
background: var(--bg-subtle);
color: var(--text-main);
}
.pf-accordion-summary::after {
content: "▼";
font-size: 0.7rem;
transition: transform 0.3s ease;
}
details[open].pf-accordion > .pf-accordion-summary {
border-bottom: 1px solid var(--border-light);
color: var(--primary);
}
details[open].pf-accordion > .pf-accordion-summary::after {
transform: rotate(-180deg);
}
.pf-accordion-content {
padding: 16px;
background: var(--bg-panel);
display: flex;
flex-direction: column;
gap: 15px;
}
/* Bouton Paramètres avec icône rotative (Roue crantée) */
.btn-settings-gear {
background: transparent;
border: none;
box-shadow: none;
outline: none;
font-size: 1.4rem;
padding: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
line-height: 1;
color: inherit;
}
.btn-settings-gear:hover {
transform: rotate(
90deg
); /* 90deg rend mieux sur une roue crantée qu'un 45deg ! */
}
[data-theme="dark"] .ts-thumb { transform: translateX(16px); }
+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>
<?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">
<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; ?>
@@ -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('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('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('liste', $mods)): ?><a href="/liste.php" class="pf-nav-link <?= $activePage === 'liste' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_liste') ?></a><?php endif; ?>
<?php if (in_array('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>
<?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('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('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('liste',$mods)): ?><a href="/liste.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_liste') ?></a><?php endif; ?>
<?php if (in_array('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>
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
<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) : '' ?>">
<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
*/
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 = {
ID_ALEX: <?php echo defined('ID_ALEX') ? ID_ALEX : 2; ?>,
ID_LAIA: <?php echo defined('ID_LAIA') ? ID_LAIA : 3; ?>,
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>'
};
// Liste complète des profils de la famille (Membres, Nounou, Enfants)
PEOPLE: <?= isset($familyPeople) ? json_encode($familyPeople) : '[]' ?>,
// ID du user global connecté (pour savoir "qui" clique)
CURRENT_USER_ID: <?= isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : 'null' ?>,
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>',
ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>'
};
function tr(key) {
return window.I18N[key] || key;
+16 -2
View File
@@ -22,6 +22,9 @@ $pageCss = "/modules/holidays/holidays.css";
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
if ($tab === 'holiday_detail' && isset($_GET['id'])) {
// 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';
}
// 4. Inclusion du JS global du module (Pont i18n déjà géré dans le header)
echo '<script src="/modules/holidays/holidays.js"></script>';
// 4. Inclusion des librairies globales du module (Flatpickr)
?>
<!-- Flatpickr (Vanilla JS Date Range) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css" id="flatpickr-dark-theme" disabled>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/fr.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/cat.js"></script>
<?php
$jsPath = __DIR__ . '/modules/holidays/holidays.js';
$jsVersion = file_exists($jsPath) ? filemtime($jsPath) : time();
echo '<script src="/modules/holidays/holidays.js?v=' . $jsVersion . '"></script>';
require __DIR__ . '/footer.php';
+27
View File
@@ -32,3 +32,30 @@ function require_login(?string $loginPage = '/login.php'): void
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 dapp iCloud, etc.).
* Ordre de priorité :
* 1. Variable denvironnement 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 lerreur si rien nest 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
// includes/db.php
if (file_exists(__DIR__ . '/config.php')) {
require_once __DIR__ . '/config.php';
}
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$host = getenv('DB_HOST') ?: 'househub-db';
$user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme';
$db = $_SESSION['family_db'] ?? null;
$envHost = getenv('DB_HOST');
if (!$db) {
$current = basename($_SERVER['PHP_SELF'] ?? '');
if (!in_array($current, ['login.php', 'register.php'])) {
header('Location: /login.php');
exit;
}
return;
// 1. Détection stricte de l'environnement
if ($envHost === 'househub-db') {
// Docker
$host = 'househub-db';
$user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme';
} else {
// XAMPP
$host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
$user = 'root';
$pass = '';
}
// 2. Multi-tenant strict : cibler la DB de la famille courante
$db = 'househub_meta'; // Fallback par défaut
if (!empty($_SESSION['user']['family_id'])) {
$db = 'househub_f' . (int)$_SESSION['user']['family_id'];
}
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30,
];
try {
$pdo = new PDO(
"mysql:host=$host;dbname=$db;charset=utf8mb4",
$user, $pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30,
]
);
$pdo = new PDO($dsn, $user, $pass, $options);
// Forcer la collation
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
$pdo->exec("SET collation_database = utf8mb4_general_ci");
$pdo->exec("SET collation_server = utf8mb4_general_ci");
} catch (\PDOException $e) {
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
die(json_encode(['ok' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]));
die("Erreur de connexion BDD (Host: $host, DB: $db) : " . $e->getMessage());
}
?>
+4 -1
View File
@@ -26,4 +26,7 @@ if (!function_exists('tr')) {
function tr($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 dapplication 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 dapp 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 lURL dun 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 quune 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 lURL dun calendrier ; sinon retourne lURL 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;
}
+423 -41
View File
@@ -22,6 +22,7 @@ return [
'menu_budget' => 'Pressupost',
'menu_holidays' => 'Viatges',
'menu_gifts' => 'Regals',
'menu_calendar_ios' => 'Calendari iOS',
'nav_home' => 'Inici',
'nav_calendar' => 'Calendari',
'nav_budget' => 'Pressupost',
@@ -90,10 +91,84 @@ return [
'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.',
'mod_garage_name' => 'Garatge',
'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Els vostres memos, receptes, instruccions i informació diversa amb Markdown.',
'menu_memo' => 'Notes',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Gestioneu les vostres tasques, llistes de la compra i recordatoris familiars.',
'mod_calendar_ios_name' => 'Calendari iOS',
'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).',
'menu_todo' => 'Todo',
'mod_groceries_name' => 'Compra',
'mod_groceries_desc' => 'Llista compartida: afegiu productes i marqueu-los quan siguin al carretó.',
'menu_groceries' => 'Compra',
'groceries_placeholder_add' => 'Ex : llet, pa, tomàquets…',
'groceries_btn_add' => 'Afegir',
'groceries_btn_uncheck_all' => 'Tornar a marcar com a pendents',
'groceries_btn_delete_picked' => 'Eliminar els marcats',
'groceries_subtitle' => '{pending} pendents · {cart} al carretó',
'groceries_added' => 'Afegit',
'groceries_updated' => 'Actualitzat',
'groceries_deleted' => 'Eliminat',
'groceries_reset_next' => 'Llista llesta per a la propera compra',
'groceries_picked_removed' => 'Articles eliminats',
'groceries_label_empty' => 'El text no pot ser buit',
'groceries_confirm_del_title' => 'Eliminar?',
'groceries_confirm_del_body' => 'Voleu treure aquest producte de la llista?',
'groceries_confirm_uncheck_title' => 'Tornar a marcar com a pendents',
'groceries_confirm_uncheck_body' => 'Es netejaran les marques del carretó. Els productes es mantindran a la llista.',
'groceries_confirm_clear_title' => 'Eliminar els marcats',
'groceries_confirm_clear_body' => 'Tots els articles marcats se suprimiran de la llista.',
'groceries_empty' => 'Encara no hi ha res. Afegiu un producte a dalt.',
'groceries_aria_toggle' => 'Marcar com al carretó o pendent',
'groceries_section_pending' => 'Pendents',
'groceries_section_cart' => 'Al carretó',
'groceries_prompt_edit' => 'Editar el producte',
'groceries_btn_clear_list' => 'Buidar la llista',
'groceries_confirm_empty_list_title' => 'Buidar tota la llista?',
'groceries_confirm_empty_list_body' => 'Es trauran tots els productes. Romandran a lhistorial per tornar-los a afegir ràpidament.',
'groceries_list_cleared' => 'Llista buidada',
'groceries_history_title' => 'Historial — un clic per afegir',
'groceries_history_empty' => 'Lhistorial es va omplint amb els productes que afegiu (fins al límit definit als paràmetres).',
'groceries_history_meta' => 'Fins a {max} productes · ',
'groceries_settings_link' => 'Paràmetres',
'groceries_settings_title' => 'Compra — historial',
'groceries_settings_intro' => 'Nombre màxim de productes recordats a lhistorial (sense duplicats, sense distingir majúscules).',
'groceries_settings_history_max' => 'Mida de lhistorial',
'groceries_settings_history_hint' => 'Entre 1 i 50. Les entrades més antigues se suprimeixen quan es supera el límit.',
'groceries_settings_saved' => 'Preferències de la compra desades.',
'groceries_settings_error' => 'No sha pogut desar:',
'groceries_err_duplicate' => 'Aquest producte ja és a la llista.',
'cta_open' => 'Obrir',
'cta_explore' => 'Explorar',
'cta_view_lists' => 'Veure les llistes',
'cta_view_lists' => 'Desembolicar',
'cta_manage' => 'Gestionar',
'cta_service' => 'Revisar',
'cta_jot' => 'Apuntar',
'cta_check' => 'Marcar',
'cta_cart' => 'Anar a comprar',
'cta_sync' => 'Sincronitzar',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Gestioneu els vostres fitxers d\'impressió 3D — STL, 3MF, GCode.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Imprimir',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Gestioneu els vostres projectes en mode Kanban amb Planka.',
'menu_planka' => 'Planka',
// --- SETTINGS : ENFANTS ---
'set_kids_title' => 'Nens de la llar',
'set_kids_desc' => 'Gestioneu la llista de nens per associar-los a l\'estalvi, als regals, etc.',
'set_add_kid_placeholder' => 'Nom del nen',
'set_btn_add_kid' => 'Afegir',
'set_err_kid_name' => 'El nom és obligatori.',
'set_confirm_del_kid' => 'Vols eliminar aquest nen? (El seu historial financer es conservarà als arxius).',
'set_no_kids' => 'Cap nen configurat per a aquesta llar.',
'set_family_config' => 'Configuració de la llar',
'set_global_params' => 'Paràmetres globals',
'set_care_modes' => 'Modalitats de cura habituals',
'set_add_care_mode' => 'Afegir una modalitat',
'btn_save' => 'Desar',
// ==========================================
// FAMILY CALENDAR
@@ -102,7 +177,7 @@ return [
'fc_main_header' => 'Calendari Familiar',
'fc_btn_correct_balances' => 'Corregir els saldos',
'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_col_period' => 'Període',
'fc_col_from' => 'Des del',
@@ -138,6 +213,122 @@ return [
'fc_err_action' => 'Error en l\'acció: ',
'fc_today_short' => 'Avui',
'fc_today_title' => 'Anar a avui',
'fc_tab_foyer' => '🏡 Llar',
'fc_tab_members' => '👥 Membres i Drets',
'fc_select_member' => 'Selecciona un membre de la família:',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Nen',
'fc_care_modes_title' => '👶 Modes de cura associats',
'fc_care_modes_desc' => 'Marca els modes de cura que s\'apliquen a aquest nen.',
'fc_no_care_mode' => 'Cap mode configurat.',
'fc_matrix_title' => '👨‍👩‍👦 Matriu de Drets de Permís',
'fc_matrix_desc' => 'Declara els codis de comptadors i les dates de renovació.',
'fc_matrix_code' => 'Codi',
'fc_matrix_date' => 'Data de Reset',
'fc_matrix_empty' => 'Cap comptador configurat.',
'fc_matrix_saved' => 'Matriu de drets actualitzada! 💼',
'fc_child_saved' => 'Subscripció del nen desada!',
'btn_add_counter' => ' Afegir',
'btn_save_rights' => '💾 Desar',
'fc_modal_settings_title' => 'Configuració del Calendari',
'fc_zone_label' => 'Zona Escolar (Vacances)',
'fc_zone_desc' => 'Determina el calendari automàtic de vacances escolars.',
'fc_care_modes_label' => 'Modes de Cura de la Llar',
'fc_care_modes_desc' => 'Gestiona la llista global de modes de cura possibles per a la família.',
'fc_add_mode_placeholder' => 'Ex: Mainadera, Casal, Avis...',
'fc_settings_updated' => 'Configuració actualitzada correctament!',
'fc_first_day' => 'Primer dia de la setmana',
// --- CALENDARI : PARÀMETRES (MEMBRES I VISUALITZACIÓ) ---
'fc_tab_display' => '🖥️ Visualització',
'fc_display_title' => 'Ajustos de visualització',
'fc_display_desc' => 'Personalitza l\'aparença i el comportament predeterminat del teu calendari.',
'fc_default_view' => 'Vista per defecte',
'fc_view_month' => '1 mes',
'fc_view_2months' => '2 mesos',
'fc_view_3months' => '3 mesos',
'fc_working_hours' => 'Horari de cura (Per defecte)',
'fc_working_hours_desc' => 'Exemple: 08:30-18:00',
'fc_role_unknown' => 'Desconegut',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Nen/a',
'fc_role_helper' => 'Ajudant',
'fc_error' => 'Error: ',
'fc_care_modes_title' => 'Modes de Cura Predeterminats',
'fc_care_modes_desc' => 'Quins modes de cura s\'apliquen a aquest nen/a?',
'fc_no_care_mode' => 'No hi ha modes de cura configurats a la llar.',
'fc_child_saved' => 'Desat!',
'fc_matrix_title' => 'Matriu de Permisos',
'fc_matrix_desc' => 'Defineix les quotes i dates de renovació.',
'fc_matrix_empty' => 'No hi ha comptadors definits.',
'fc_matrix_saved' => 'Matriu desada!',
'fc_leave_obsolete' => 'Obsolet',
'fc_leave_code_placeholder' => 'Codi...',
'fc_leave_method_fixed' => 'Fix',
'fc_leave_method_accumulated' => 'Acumulat',
'fc_date_format_placeholder' => 'DD/MM',
'fc_date_format_title' => 'Format DD/MM',
'fc_table_code' => 'Codi',
'fc_table_method' => 'Mètode',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renov.',
'btn_save_rights' => 'Guardar',
// ==========================================
// MODAL DE CONFIGURACIÓ DEL CALENDARI
// ==========================================
'fc_modal_settings_title' => 'Configuració del calendari',
'fc_tab_foyer' => 'Llar',
'fc_tab_members' => 'Membres i Drets',
// Llar: Zona escolar
'fc_zone_label' => 'Zona escolar',
'fc_zone_a' => 'Zona A',
'fc_zone_b' => 'Zona B',
'fc_zone_c' => 'Zona C',
'fc_zone_other' => 'Altra / Fora de França',
'fc_zone_desc' => 'Permet mostrar correctament les vacances escolars.',
// Llar: Opcions de cura (Modes de garde)
'fc_care_modes_label' => 'Modalitats de cura',
'fc_add_mode_placeholder' => 'Ex: Mainadera, Llar d\'infants...',
'fc_care_modes_desc' => 'Afegeix les diferents modalitats de cura per als teus fills.',
'fc_empty_kids_msg' => 'Actualment no hi ha cap infant configurat a la teva família.',
'fc_link_settings' => 'Anar a la configuració general',
// Llar: Catàleg de permisos (Congés)
'fc_leave_catalog_title' => 'Catàleg de permisos',
'fc_leave_catalog_desc' => 'Defineix els tipus de permisos possibles per a tota la família.',
'fc_add_leave_type' => 'Afegir un tipus de permís',
'fc_col_code' => 'Codi',
'fc_code_irreversible' => 'Irreversible',
'fc_col_label' => 'Etiqueta',
'fc_leave_label_ph' => 'Ex: Vacances, Assumptes propis...',
'fc_quota_default' => 'Quota per defecte',
'fc_reset_month' => 'Mes de renovació',
// Membres
'fc_select_member' => 'Selecciona un membre:',
// Mesos
'month_jan' => 'Gener',
'month_feb' => 'Febrer',
'month_mar' => 'Març',
'month_apr' => 'Abril',
'month_may' => 'Maig',
'month_jun' => 'Juny',
'month_jul' => 'Juliol',
'month_aug' => 'Agost',
'month_sep' => 'Setembre',
'month_oct' => 'Octubre',
'month_nov' => 'Novembre',
'month_dec' => 'Desembre',
'leg_presence' => 'Presència Pep',
'leg_school_holidays' => 'Vacances Escolars',
@@ -147,6 +338,9 @@ return [
'leg_centre' => 'Casal',
'leg_avis' => 'Avís',
'leg_pep_sick' => 'Pep Malalt',
'care_nounou' => 'Kangur',
'care_centre' => 'Casal',
'care_avis' => 'Avis',
'vac_toussaint' => 'Vacances de Tots Sants',
'vac_noel' => 'Vacances de Nadal',
@@ -324,6 +518,71 @@ return [
'cat_fixed' => 'Despeses Fixes',
'cat_others' => 'Altres / Imprevistos',
'cat_savings' => 'Estalvis',
'budget_tab_kids' => 'Nens',
'budget_settings_title' => 'Configuració del Pressupost',
'bs_tab_accounts' => '💳 Comptes',
'bs_tab_categories' => '🏷️ Categories',
'bs_tab_rules' => '🤖 Regles d\'importació',
'bs_tab_salaries' => '💼 Salaris',
'bs_empty_accounts' => 'Cap compte configurat.',
'bs_type_checking' => 'Corrent',
'bs_type_savings' => 'Estalvi',
'bs_badge_default' => 'Per defecte',
'bs_owner' => 'Propietari',
'bs_shared_account' => 'Compte conjunt',
'bs_empty_categories' => 'Cap categoria configurada.',
'bs_type_income' => 'Ingrés',
'bs_type_expense' => 'Despesa',
'bs_type_savings_cat' => 'Estalvi',
'bs_empty_rules' => 'Cap regla d\'importació.',
'bs_empty_salaries' => 'Cap salari definit per a l\'any',
'bs_salaries_year_desc' => 'Configuracions per a l\'any',
'bs_salary_net' => 'Salari net',
'bs_salary_fees' => 'Mensualitat / Despeses',
'bs_add_account_title' => 'Afegir un compte',
'bs_account_name_ph' => 'Nom (ex: Estalvi Bebè)',
'bs_confirm_delete' => 'Segur que voleu suprimir aquest compte?',
'bs_add_category_title' => '+ Afegir una categoria',
'bs_cat_code_ph' => 'Codi (ex: FOOD)',
'bs_cat_label_ph' => 'Etiqueta (ex: Compres)',
'bs_cat_icon_ph' => 'Icona (ex: 🛒)',
'bs_confirm_delete_cat' => 'Segur que voleu suprimir aquesta categoria?',
'bs_add_rule_title' => 'Afegir una regla',
'bs_rule_keyword_ph' => 'Paraula clau (ex: AUCHAN)',
'bs_confirm_delete_rule'=> 'Segur que voleu suprimir aquesta regla?',
'bs_currency_label' => 'Divisa de la llar',
'bs_currency_desc' => 'Divisa principal utilitzada per mostrar els vostres comptes i pressupostos.',
'bs_currency_updated' => 'Divisa actualitzada correctament!',
// ONGLET CSV MAPPING
'bs_csv_title' => 'Format de la importació CSV',
'bs_csv_desc' => 'Configureu aquí les columnes que corresponen a l\'exportació del vostre banc.',
'bs_csv_delimiter' => 'Separador',
'bs_csv_delim_semi' => 'Punt i coma (;)',
'bs_csv_delim_comma' => 'Coma (,)',
'bs_csv_delim_tab' => 'Tabulació',
'bs_csv_date_format' => 'Format de la Data',
'bs_csv_col_index' => 'Índex de les columnes',
'bs_csv_col_index_help' => '(Atenció: la 1a columna és la Número 0)',
'bs_csv_col_date' => 'Núm Col: Data',
'bs_csv_col_label' => 'Núm Col: Concepte / Motiu',
'bs_csv_amount_mgmt' => 'Gestió dels imports',
'bs_csv_amount_single' => 'Una sola columna (Positiu = Ingrés, Negatiu = Despesa)',
'bs_csv_amount_split' => 'Dues columnes separades (Dèbit / Crèdit)',
'bs_csv_col_amount' => 'Núm Col: Import',
'bs_csv_col_debit' => 'Núm Col: Dèbit',
'bs_csv_col_credit' => 'Núm Col: Crèdit',
'bs_csv_col_ref' => 'Núm Col: Referència única (Opcional)',
'btn_save_format' => 'Desar el format',
'bs_csv_saved' => 'Format CSV desat',
'bs_tab_csv' => '📥 Importació CSV',
'bs_csv_settings_title' => 'Configuració del format',
'bs_csv_drop_title' => 'Arrossegueu el vostre fitxer CSV aquí',
'bs_csv_drop_desc' => 'o feu clic per cercar fitxers',
'bs_csv_err_type' => 'Error: Només s\'admeten fitxers .csv.',
'bs_csv_file_ok' => 'Fitxer carregat',
'bs_csv_preview_title' => 'Previsualització de les vostres columnes',
'bs_csv_empty_col' => 'Buit',
'bs_csv_no_data' => 'No s\'ha trobat cap línia de transacció en aquest fitxer.',
// --- BUDGET : SUIVI ---
'bud_rem_school' => 'Escola (Resta estimada)',
@@ -381,6 +640,26 @@ return [
'bud_bar_actual' => "Actual",
'bud_bar_theoretical' => "Teòric",
'bud_bar_max_cap' => "Capacitat Màx.",
'bud_edit_title' => 'Modificar la transacció',
'bud_to_define_js' => 'per definir',
'bud_select_beneficiary' => 'Triar un ingrés esperat',
'bud_bar_actual' => 'Actual',
// ONBOARDING BUDGET
'bs_onb_title' => 'Benvinguts! Configurem el teu pressupost:',
'bs_onb_desc' => 'Completa aquests passos perquè l\'aplicació pugui calcular el teu pressupost disponible:',
'bs_onb_cat_link' => 'Categories',
'bs_onb_cat_desc' => 'Crea almenys una categoria de despeses.',
'bs_onb_acc_link' => 'Comptes Bancaris',
'bs_onb_acc_desc' => 'Afegeix almenys un compte corrent o d\'estalvi.',
'bs_onb_sal_link' => 'Sous',
'bs_onb_sal_desc' => 'Introdueix els ingressos de l\'any en curs.',
'bs_onb_items_link' => 'Línies de Pressupost',
'bs_onb_items_desc' => 'Ves a la pestanya Resum per llistar les teves despeses fixes (Lloguer, Assegurances...).',
'bs_rule_search_ph' => '🔍 Cercar una paraula clau (ex: AUCHAN)...',
'error_loading_settings' => 'Error en carregar la configuració',
'settings' => 'Configuració',
// --- MODULE BUDGET : IMPORT CSV ---
'bud_import_csv' => "Importar CSV",
'bud_csv_file' => "Seleccioneu el fitxer del vostre banc (.csv)",
@@ -438,6 +717,9 @@ return [
'bud_sav_modal_title_add' => 'Introduir un mes',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_prev_label_name' => 'Nom',
'bud_prev_monthly_target' => 'Import objectiu / Mensualitat (€)',
'bud_prev_transfer_dest' => 'Destinació de la transferència',
'bud_prev_none' => 'Cap destinació (Despesa estàndard)',
// --- BUDGET : EPARGNE ---
'bud_sav_add_month' => 'Introduir un mes',
@@ -468,6 +750,60 @@ return [
'bud_sav_prompt_duplicate' => "Vols copiar les dades de %s a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
'bud_err_server' => 'Error del servidor: ',
'bud_err_network_dup' => 'Error de xarxa en duplicar.',
'budget_tab_kids' => 'Infants 👶',
'bud_sav_add_one_month' => '+1 Mes',
'bud_sav_add_month' => 'Afegir un mes',
'bud_sav_no_data' => 'Cap dada d\'estalvi per a %s.',
'bud_sav_post_month' => 'Concepte / Mes',
'bud_sav_from_date' => 'Des del %s',
'bud_sav_edit_modal' => 'Editar aquest mes',
'bud_sav_delete_month' => 'Suprimir aquest mes',
'bud_sav_total_bank' => 'TOTAL BANC',
'bud_sav_extra' => 'NO ASSIGNAT (EXTRES)',
'bud_sav_modal_title_add' => 'Afegir un mes',
'bud_sav_modal_title_edit' => 'Editar',
'bud_sav_month_concerned' => 'Mes afectat',
'bud_sav_total_bank_eur' => 'Total al Banc (€)',
'bud_sav_ventilation' => 'Desglossament de comptes',
'bud_sav_adj_help' => 'Utilitzeu l\'ajust (+/-) per recalcular automàticament.',
'bud_sav_add_line' => 'Afegir una línia',
'bud_category' => 'Categoria',
'bud_sav_current' => 'Actual',
'bud_sav_adjust' => 'Ajust',
'bud_sav_new' => 'Nou',
'bud_sav_sum_mode_title' => 'Activar/Desactivar la calculadora',
'bud_sav_selection' => 'Selecció:',
'bud_sav_ph_name' => 'Nom del compte',
// --- Module Budget : Provisions & Optimisation ---
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
'btn_optimize_cashflow' => 'Optimiser mes Économies',
'add_new_provision' => 'Anticiper une grosse dépense à venir',
'provision_label' => 'Libellé de la dépense',
'provision_placeholder_wood' => 'Ex: Commande de bois, Révision de l\'auto...',
'amount' => 'Montant',
'expected_date' => 'Date prévue',
'btn_add' => 'Ajouter',
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
'error_generic' => 'Une erreur est survenue',
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
'btn_delete' => 'Supprimer',
'savings_this_month' => 'Économies globales à placer ce mois-ci',
'calculate' => 'Calculer la stratégie optimale',
'date_past_warning' => 'La date sélectionnée est déjà passée !',
'budget_tab_provisions' => 'Provisions',
// JS & Errors
'bud_sav_confirm_delete_month' => 'Estàs segur que vols suprimir totes les dades de %m per a %o?',
'bud_sav_prompt_duplicate' => "Vols duplicar les dades de %s cap a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
'bud_err_tech' => 'S\'ha produït un error tècnic.',
'bud_err_server' => 'Error del servidor: ',
'bud_err_network_dup' => 'Error de xarxa en duplicar.',
'bud_sav_saving' => 'Desant...',
'bud_err_delete' => 'Error en suprimir.',
// --- BUDGET : PREVISIONNEL ---
'bud_prev_incomes' => 'Ingressos',
@@ -503,49 +839,95 @@ return [
'bud_prev_err_no_history' => 'Impossible: no hi ha historial disponible per copiar.',
'bud_prev_confirm_copy' => "Vols copiar les dades de %s a %t?\n\n⚠️ Això sobreescriurà tots els valors ja presents per a %t.",
'bud_prev_confirm_transfers' => "Confirmes que %p ha fet totes les transferències per a %m?\n\nAixò actualitzarà l'Estalvi automàticament.",
'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_transfer_to' => 'Cap a',
// --- MODULE BUDGET : AVANCES & TRICOUNT ---
'bud_adv_title' => 'Avançaments i Tricount Familiar',
'bud_adv_add' => 'Afegir un avançament',
'bud_adv_edit' => 'Modificar l\'avançament',
'bud_adv_who_paid' => 'Qui ha pagat?',
'bud_adv_payer' => 'Pagador',
'bud_adv_has_advanced' => '%s ha avançat:',
'bud_adv_cc_label' => 'al compte comú',
'bud_adv_livret_label' => 'des del seu llibre d\'estalvis',
'bud_adv_cc_balance_title' => 'Equilibri del Compte Comú',
'bud_adv_owed_to' => 'S\'han de pagar **%s €** a **%s**',
'bud_adv_balanced' => 'Els comptes estan perfectament equilibrats!',
'bud_adv_saved_badge' => 'Estalvis',
'bud_adv_already_saved' => 'Aquests diners provenen d\'un llibre d\'estalvis',
'bud_adv_ph_desc' => 'Ex: Compres, Factura d\'electricitat...',
'bud_adv_confirm_resolve' => 'Confirmar el reemborsament complet d\'aquest avançament?',
'bud_adv_confirm_delete' => 'Eliminar definitivament aquest avançament?',
// ==========================================
// CADEAUX (GIFTS)
// MÒDUL: REGALS (gift-list)
// ==========================================
'gift_page_title' => 'HouseHub - Llista de regals',
'gift_occ_tio' => 'Tió',
'gift_occ_noel' => 'Nadal',
'gift_occ_rois' => 'Reis',
'gift_occ_anniv' => 'Aniversari',
'gift_occ_sant' => 'Sant',
'gift_main_title' => 'Llista de regals %s',
'gift_aria_change_view' => 'Canvia la vista',
'gift_view_nadal' => 'Nadal',
'gift_view_anniv' => 'Aniversaris',
'gift_view_by_party' => 'Vista per festa',
'gift_no_gifts' => 'No hi ha cap regal registrat per a %s en aquesta vista.',
'gift_add_gift' => 'Afegeix un regal',
'gift_paid_by' => 'pagat per %s',
'gift_summary_title' => 'Resum del pressupost',
'gift_col_adult' => 'Adult',
'gift_col_child' => 'Infant',
'gift_col_party' => 'Festa',
'gift_debtor' => 'Deutor',
'gift_creditor' => 'Creditor',
'gift_liquidations' => 'Tricount',
'gift_no_debt' => 'Cap deute pendent.',
'gift_owes' => 'ha de pagar',
'gift_to' => 'a',
'gift_detailed_list' => 'Llista detallada de regals',
'gift_col_gift' => 'Regal',
'gift_col_link' => 'Enllaç',
'gift_modal_title_add' => 'Afegeix un regal per a %s',
'gift_modal_title_edit' => 'Edita el regal',
// 1. Títols i capçaleres
'gift_page_title' => 'Llista de Regals',
'gift_main_title' => 'Regals de %s', // %s serà substituït per l'any (ex: Regals de 2026)
'settings' => 'Configuració',
// 2. Filtres
'gift_filter_all_children' => 'Tots els nens',
'gift_filter_all_adults' => 'Tots els adults',
// 3. Contingut i estats buits
'gift_add_gift' => 'Afegir',
'gift_empty_state_no_gifts' => 'Cap regal registrat per a aquesta ocasió.',
'gift_empty_state_no_filter' => 'Cap regal coincideix amb els teus filtres.',
'gift_paid_by' => 'Pagat per %s', // %s serà substituït pel nom del pagador
// 4. Secció Tricount (Balanç i Reemborsaments)
'gift_liquidations' => 'Balanç i Reemborsaments',
'gift_no_debt' => 'Tot està al dia, cap deute!',
'gift_owes' => 'deu',
'gift_view_matrix' => 'Veure la matriu detallada',
'gift_debtor' => 'Deutor',
'gift_creditor' => 'Creditor',
// 5. Modal: Afegir/Modificar Regal
'gift_col_adult' => 'Per compte de',
'gift_modal_payer' => 'Pagat per',
'gift_modal_gift_name' => 'Nom del regal',
'gift_modal_ph_name' => 'p. ex., Lego Star Wars',
'gift_modal_price' => 'Preu (€)',
'gift_modal_link' => 'Enllaç (opcional)',
'gift_confirm_delete' => 'Vols eliminar aquest regal?',
'gift_filter_all_children' => 'Tots els nens',
'gift_filter_all_adults' => 'Tots els adults',
'gift_empty_state_no_gifts' => 'Cap regal de moment.',
'gift_empty_state_no_filter' => 'Cap regal correspon al filtre.',
'gift_modal_gift_name' => 'Descripció del regal',
'gift_modal_price' => 'Preu estimat / pagat (€)',
'gift_modal_link' => 'Enllaç del producte (URL)',
'gift_modal_title_add' => 'Afegir per a %s', // JS: %s = nom del nen
'gift_modal_title_edit' => 'Modificar el regal', // JS
'gift_confirm_delete' => 'Vols eliminar realment aquest regal?', // JS
// 6. Modal: Configuració de les Festes
'gift_settings_title' => 'Configuració de les Festes',
'gift_settings_add_title' => '+ Afegir una ocasió',
'gift_settings_name_placeholder' => 'Nom (ex: Sant Valentí)',
'gift_settings_date_placeholder' => 'MM-DD',
'gift_settings_date_help' => 'La data (MM-DD) és opcional. Deixa en blanc per a una festa mòbil.',
'gift_settings_empty' => 'Cap festa configurada.', // JS
'gift_settings_active' => 'Actiu', // JS
// 7. Botons genèrics
'btn_cancel' => 'Cancel·lar',
'btn_save' => 'Desar',
'btn_add' => 'Afegir',
'loading' => 'Carregant...', //
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings' => 'Configuració',
'gift_tab_occasions' => 'Festes i Ocasions',
'gift_tab_proches' => 'Familiars (Fora de la llar)',
'gift_settings_icon' => 'Icona',
'gift_settings_date' => 'Data (MM-DD)',
'gift_settings_add_occ' => 'Afegeix una festa',
'gift_settings_add_proche'=> 'Afegeix un familiar',
'gift_role_proche_adulte'=> 'Adult (Oncle, amic...)',
'gift_role_proche_enfant'=> 'Nen (Nebot, neboda...)',
'gift_ph_proche_name' => 'Nom',
'gift_role_label' => 'Rol',
'gift_settings_saved' => 'Configuració desada!',
'gift_settings_edit_occ' => 'Modificar la festa',
'gift_settings_edit_proche' => 'Modificar el familiar',
// ==========================================
// GARAGE MANAGER
// ==========================================
+422 -42
View File
@@ -23,6 +23,7 @@ return [
'menu_budget' => 'Budget',
'menu_holidays' => 'Trips',
'menu_gifts' => 'Gifts',
'menu_calendar_ios' => 'iOS Calendar',
'nav_home' => 'Home',
'nav_calendar' => 'Calendar',
'nav_budget' => 'Budget',
@@ -91,10 +92,84 @@ return [
'mod_budget_desc' => 'Track fixed expenses, income and family account balance.',
'mod_garage_name' => 'Garage',
'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Your memos, recipes, instructions and miscellaneous info with Markdown and attachments.',
'menu_memo' => 'Notes',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Manage tasks, shopping lists and family reminders.',
'mod_calendar_ios_name' => 'iOS Calendar',
'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).',
'menu_todo' => 'Todo',
'mod_groceries_name' => 'Groceries',
'mod_groceries_desc' => 'Shared list: add items and check them off when they are in your cart.',
'menu_groceries' => 'Groceries',
'groceries_placeholder_add' => 'e.g. milk, bread, tomatoes…',
'groceries_btn_add' => 'Add',
'groceries_btn_uncheck_all' => 'Reset cart checks',
'groceries_btn_delete_picked' => 'Remove checked items',
'groceries_subtitle' => '{pending} to pick up · {cart} in cart',
'groceries_added' => 'Added',
'groceries_updated' => 'Updated',
'groceries_deleted' => 'Removed',
'groceries_reset_next' => 'List ready for your next trip',
'groceries_picked_removed' => 'Checked items removed',
'groceries_label_empty' => 'Label cannot be empty',
'groceries_confirm_del_title' => 'Delete?',
'groceries_confirm_del_body' => 'Remove this product from the list?',
'groceries_confirm_uncheck_title' => 'Reset cart checks',
'groceries_confirm_uncheck_body' => 'All “in cart” checkmarks will be cleared. Items stay on the list.',
'groceries_confirm_clear_title' => 'Remove checked items',
'groceries_confirm_clear_body' => 'Every checked item will be deleted from the list.',
'groceries_empty' => 'Nothing yet. Add a product above.',
'groceries_aria_toggle' => 'Mark as in cart or still to pick up',
'groceries_section_pending' => 'To pick up',
'groceries_section_cart' => 'In cart',
'groceries_prompt_edit' => 'Edit product',
'groceries_btn_clear_list' => 'Clear list',
'groceries_confirm_empty_list_title' => 'Clear the entire list?',
'groceries_confirm_empty_list_body' => 'All products will be removed. They stay in history so you can add them again quickly.',
'groceries_list_cleared' => 'List cleared',
'groceries_history_title' => 'History — click to add',
'groceries_history_empty' => 'History fills as you add products (up to the limit set in Settings).',
'groceries_history_meta' => 'Up to {max} products · ',
'groceries_settings_link' => 'Settings',
'groceries_settings_title' => 'Groceries — history',
'groceries_settings_intro' => 'Maximum number of products remembered in history (no duplicates, case-insensitive).',
'groceries_settings_history_max' => 'History size',
'groceries_settings_history_hint' => 'Between 1 and 50. Oldest entries are removed when the limit is exceeded.',
'groceries_settings_saved' => 'Grocery preferences saved.',
'groceries_settings_error' => 'Could not save:',
'groceries_err_duplicate' => 'This product is already on the list.',
'cta_open' => 'Open',
'cta_explore' => 'Explore',
'cta_view_lists' => 'View lists',
'cta_view_lists' => 'Unwrap',
'cta_manage' => 'Manage',
'cta_service' => 'Service',
'cta_jot' => 'Jot down',
'cta_check' => 'Check off',
'cta_cart' => 'Go shopping',
'cta_sync' => 'Sync',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Manage your 3D print files — STL, 3MF, GCode with interactive viewer.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Print',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Manage your projects in Kanban mode with Planka.',
'menu_planka' => 'Planka',
// --- SETTINGS : KIDS ---
'set_kids_title' => 'Household Kids',
'set_kids_desc' => 'Manage the list of kids to associate them with savings, gifts, etc.',
'set_add_kid_placeholder' => 'Kid\'s first name',
'set_btn_add_kid' => 'Add',
'set_err_kid_name' => 'The first name is required.',
'set_confirm_del_kid' => 'Delete this kid? (Their financial history will remain visible in the archives).',
'set_no_kids' => 'No kids configured for this household.',
'set_family_config' => 'Family Structure',
'set_global_params' => 'Global settings',
'set_care_modes' => 'Common childcare arrangements',
'set_add_care_mode' => 'Ajouter un modeAdd a mode',
'btn_save' => 'Save',
// ==========================================
// FAMILY CALENDAR
@@ -103,7 +178,7 @@ return [
'fc_main_header' => 'Family Calendar',
'fc_btn_correct_balances' => 'Adjust balances',
'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_col_period' => 'Period',
'fc_col_from' => 'From',
@@ -139,6 +214,122 @@ return [
'fc_err_action' => 'Error during action: ',
'fc_today_short' => 'Today',
'fc_today_title' => 'Today',
'fc_tab_foyer' => '🏡 Household',
'fc_tab_members' => '👥 Members & Rights',
'fc_select_member' => 'Select a family member:',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Child',
'fc_care_modes_title' => 'Associated Care Modes',
'fc_care_modes_desc' => 'Check the care modes that apply to this child.',
'fc_no_care_mode' => 'No care mode configured.',
'fc_matrix_title' => '👨‍👩‍👦 Leave Rights Matrix',
'fc_matrix_desc' => 'Declare your counter codes and renewal dates.',
'fc_matrix_code' => 'Code',
'fc_matrix_date' => 'Reset Date',
'fc_matrix_empty' => 'No counter configured.',
'fc_matrix_saved' => 'Rights matrix updated successfully! 💼',
'fc_child_saved' => 'Child subscription saved!',
'btn_add_counter' => ' Add',
'btn_save_rights' => '💾 Save',
'fc_modal_settings_title' => 'Calendar Settings',
'fc_zone_label' => 'School Zone (Holidays)',
'fc_zone_desc' => 'Determines the automatic school holiday calendar.',
'fc_care_modes_label' => 'Household Care Modes',
'fc_care_modes_desc' => 'Manage the global list of possible care modes for the family.',
'fc_add_mode_placeholder' => 'E.g., Nanny, Center, Grandparents...',
'fc_settings_updated' => 'Settings updated successfully!',
'fc_first_day' => 'First day of the week',
// --- CALENDAR: SETTINGS (MEMBERS & DISPLAY) ---
'fc_tab_display' => '🖥️ Display',
'fc_display_title' => 'Display Settings',
'fc_display_desc' => 'Customize the default appearance and behavior of your calendar.',
'fc_default_view' => 'Default View',
'fc_view_month' => '1 month',
'fc_view_2months' => '2 months',
'fc_view_3months' => '3 months',
'fc_working_hours' => 'Default Care Hours',
'fc_working_hours_desc' => 'Example: 08:30-18:00',
'fc_role_unknown' => 'Unknown',
'fc_role_adult' => 'Adult',
'fc_role_child' => 'Child',
'fc_role_helper' => 'Helper',
'fc_error' => 'Error: ',
'fc_care_modes_title' => 'Default Care Modes',
'fc_care_modes_desc' => 'Which care modes apply to this child?',
'fc_no_care_mode' => 'No care mode configured for this household.',
'fc_child_saved' => 'Saved!',
'fc_matrix_title' => 'Leave Matrix',
'fc_matrix_desc' => 'Define quotas and renewal dates.',
'fc_matrix_empty' => 'No counters defined.',
"fc_matrix_saved" => "Matrix saved!",
'fc_leave_obsolete' => 'Obsolete',
'fc_leave_code_placeholder' => 'Code...',
'fc_leave_method_fixed' => 'Fixed',
'fc_leave_method_accumulated' => 'Accrued',
'fc_date_format_placeholder' => 'DD/MM',
'fc_date_format_title' => 'DD/MM Format',
'fc_table_code' => 'Code',
'fc_table_method' => 'Method',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renewal',
'btn_save_rights' => 'Save',
// ==========================================
// CALENDAR SETTINGS MODAL
// ==========================================
'fc_modal_settings_title' => 'Calendar Settings',
'fc_tab_foyer' => 'Household',
'fc_tab_members' => 'Members & Rights',
// Household: School Zone
'fc_zone_label' => 'School zone',
'fc_zone_a' => 'Zone A',
'fc_zone_b' => 'Zone B',
'fc_zone_c' => 'Zone C',
'fc_zone_other' => 'Other / Outside France',
'fc_zone_desc' => 'Used to accurately display school holidays.',
// Household: Care Modes
'fc_care_modes_label' => 'Childcare options',
'fc_add_mode_placeholder' => 'Ex: Nanny, Daycare...',
'fc_care_modes_desc' => 'Add the different childcare options for your children.',
'fc_empty_kids_msg' => 'No children are configured in your household yet.',
'fc_link_settings' => 'Go to general settings',
// Household: Leave Catalog
'fc_leave_catalog_title' => 'Leave Catalog',
'fc_leave_catalog_desc' => 'Define the possible leave types for the whole family.',
'fc_add_leave_type' => 'Add a leave type',
'fc_col_code' => 'Code',
'fc_code_irreversible' => 'Irreversible',
'fc_col_label' => 'Label',
'fc_leave_label_ph' => 'Ex: PTO, Sick leave...',
'fc_quota_default' => 'Default quota',
'fc_reset_month' => 'Reset month',
// Members
'fc_select_member' => 'Select a member:',
// Months
'month_jan' => 'January',
'month_feb' => 'February',
'month_mar' => 'March',
'month_apr' => 'April',
'month_may' => 'May',
'month_jun' => 'June',
'month_jul' => 'July',
'month_aug' => 'August',
'month_sep' => 'September',
'month_oct' => 'October',
'month_nov' => 'November',
'month_dec' => 'December',
'leg_presence' => 'Pep presence',
'leg_school_holidays' => 'School holidays',
@@ -148,6 +339,9 @@ return [
'leg_centre' => 'Centre',
'leg_avis' => 'Notice',
'leg_pep_sick' => 'Pep sick',
'care_nounou' => 'Nanny',
'care_centre' => 'Daycare',
'care_avis' => 'Grandparents',
'vac_toussaint' => 'All Saints\' holidays',
'vac_noel' => 'Christmas holidays',
@@ -322,6 +516,86 @@ return [
'cat_fixed' => 'Fixed costs',
'cat_others' => 'Other / Unexpected',
'cat_savings' => 'Savings',
'budget_tab_kids' => 'Kids',
'budget_settings_title' => 'Budget Settings',
'bs_tab_accounts' => '💳 Accounts',
'bs_tab_categories' => '🏷️ Categories',
'bs_tab_rules' => '🤖 Import Rules',
'bs_tab_salaries' => '💼 Salaries',
'bs_empty_accounts' => 'No accounts configured.',
'bs_type_checking' => 'Checking',
'bs_type_savings' => 'Savings',
'bs_badge_default' => 'Default',
'bs_owner' => 'Owner',
'bs_shared_account' => 'Joint Account',
'bs_empty_categories' => 'No categories configured.',
'bs_type_income' => 'Income',
'bs_type_expense' => 'Expense',
'bs_type_savings_cat' => 'Savings',
'bs_empty_rules' => 'No import rules configured.',
'bs_empty_salaries' => 'No salary defined for the year',
'bs_salaries_year_desc' => 'Configurations for the year',
'bs_salary_net' => 'Net salary',
'bs_salary_fees' => 'Monthly payment / Fees',
'bs_add_account_title' => 'Add an account',
'bs_account_name_ph' => 'Name (e.g., Baby Savings)',
'bs_confirm_delete' => 'Are you sure you want to delete this account?',
'bs_add_category_title' => '+ Add a category',
'bs_cat_code_ph' => 'Code (e.g., FOOD)',
'bs_cat_label_ph' => 'Label (e.g., Groceries)',
'bs_cat_icon_ph' => 'Icon (e.g., 🛒)',
'bs_confirm_delete_cat' => 'Are you sure you want to delete this category?',
'bs_add_rule_title' => 'Add a rule',
'bs_rule_keyword_ph' => 'Keyword (e.g., WALMART)',
'bs_confirm_delete_rule'=> 'Are you sure you want to delete this rule?',
'bs_currency_label' => 'Household Currency',
'bs_currency_desc' => 'Main currency used for displaying your accounts and budgets.',
'bs_currency_updated' => 'Currency updated successfully!',
// ONGLET CSV MAPPING
'bs_csv_title' => 'CSV Import Format',
'bs_csv_desc' => 'Configure the columns that match your bank\'s export file.',
'bs_csv_delimiter' => 'Delimiter',
'bs_csv_delim_semi' => 'Semicolon (;)',
'bs_csv_delim_comma' => 'Comma (,)',
'bs_csv_delim_tab' => 'Tab',
'bs_csv_date_format' => 'Date Format',
'bs_csv_col_index' => 'Column Indexes',
'bs_csv_col_index_help' => '(Note: the 1st column is Number 0)',
'bs_csv_col_date' => 'Col #: Date',
'bs_csv_col_label' => 'Col #: Label / Description',
'bs_csv_amount_mgmt' => 'Amount Management',
'bs_csv_amount_single' => 'Single column (Positive = Income, Negative = Expense)',
'bs_csv_amount_split' => 'Two separate columns (Debit / Credit)',
'bs_csv_col_amount' => 'Col #: Amount',
'bs_csv_col_debit' => 'Col #: Debit',
'bs_csv_col_credit' => 'Col #: Credit',
'bs_csv_col_ref' => 'Col #: Unique Reference (Optional)',
'btn_save_format' => 'Save format',
'bs_csv_saved' => 'CSV format saved',
'bs_tab_csv' => '📥 CSV Import',
'bs_csv_settings_title' => 'Format Configuration',
'bs_csv_drop_title' => 'Drop your CSV file here',
'bs_csv_drop_desc' => 'or click to browse your files',
'bs_csv_err_type' => 'Error: Only .csv files are allowed.',
'bs_csv_file_ok' => 'File loaded',
'bs_csv_preview_title' => 'Columns Preview',
'bs_csv_empty_col' => 'Empty',
'bs_csv_no_data' => 'No transaction row found in this file.',
// ONBOARDING BUDGET
'bs_onb_title' => 'Welcome! Let\'s set up your budget:',
'bs_onb_desc' => 'Complete these steps so the app can calculate your remaining budget:',
'bs_onb_cat_link' => 'Categories',
'bs_onb_cat_desc' => 'Create at least one expense category.',
'bs_onb_acc_link' => 'Bank Accounts',
'bs_onb_acc_desc' => 'Add at least one checking or savings account.',
'bs_onb_sal_link' => 'Salaries',
'bs_onb_sal_desc' => 'Enter your income for the current year.',
'bs_onb_items_link' => 'Budget Items',
'bs_onb_items_desc' => 'Go to the Summary tab to list your fixed expenses (Rent, Insurance...).',
'bs_rule_search_ph' => '🔍 Search for a keyword (e.g. WALMART)...',
'error_loading_settings' => 'Error loading settings',
'settings' => 'Settings',
// --- BUDGET: TRACKING ---
'bud_rem_school' => 'School (estimated remaining)',
@@ -421,6 +695,9 @@ return [
'bud_reg_none' => 'None',
'bud_recap_confirm_delete' => 'Are you sure you want to delete this item?',
'bud_err_delete' => 'Error while deleting.',
'bud_prev_monthly_target' => 'Target amount / Monthly payment (€)',
'bud_prev_transfer_dest' => 'Transfer destination',
'bud_prev_none' => 'No destination (Standard expense)',
// --- BUDGET: SAVINGS ---
'bud_sav_add_month' => 'Enter a month',
@@ -451,6 +728,39 @@ return [
'bud_sav_prompt_duplicate' => "Do you want to copy data from %s to %t1?\n\nEnter the new TOTAL bank balance (€) for %t2:",
'bud_err_server' => 'Server error: ',
'bud_err_network_dup' => 'Network error during duplication.',
'budget_tab_kids' => 'Kids 👶',
'bud_sav_add_one_month' => '+1 Month',
'bud_sav_add_month' => 'Add a month',
'bud_sav_no_data' => 'No savings data for %s.',
'bud_sav_post_month' => 'Item / Month',
'bud_sav_from_date' => 'From %s',
'bud_sav_edit_modal' => 'Edit this month',
'bud_sav_delete_month' => 'Delete this month',
'bud_sav_total_bank' => 'TOTAL BANK',
'bud_sav_extra' => 'UNALLOCATED (EXTRAS)',
'bud_sav_modal_title_add' => 'Add a month',
'bud_sav_modal_title_edit' => 'Edit',
'bud_sav_month_concerned' => 'Target month',
'bud_sav_total_bank_eur' => 'Total in Bank (€)',
'bud_sav_ventilation' => 'Accounts breakdown',
'bud_sav_adj_help' => 'Enter adjustments (+/-) to auto-calculate.',
'bud_sav_add_line' => 'Add line',
'bud_category' => 'Category',
'bud_sav_current' => 'Current',
'bud_sav_adjust' => 'Adjustment',
'bud_sav_new' => 'New',
'bud_sav_sum_mode_title' => 'Toggle calculator',
'bud_sav_selection' => 'Selection:',
'bud_sav_ph_name' => 'Account name',
// JS & Errors
'bud_sav_confirm_delete_month' => 'Are you sure you want to delete all data of %m for %o?',
'bud_sav_prompt_duplicate' => "Do you want to duplicate data from %s to %t1?\n\nEnter the new BANK TOTAL (€) for %t2:",
'bud_err_tech' => 'A technical error occurred.',
'bud_err_server' => 'Server error: ',
'bud_err_network_dup' => 'Network error during duplication.',
'bud_sav_saving' => 'Saving...',
'bud_err_delete' => 'Error during deletion.',
// --- BUDGET: FORECAST ---
'bud_prev_incomes' => 'Income',
@@ -487,50 +797,120 @@ return [
'bud_prev_confirm_copy' => "Do you want to copy data from %s to %t?\n\n⚠️ This will overwrite all values already present for %t.",
'bud_prev_confirm_transfers' => "Confirm that %p has made all their transfers for %m?\n\nThis will automatically update Savings.",
'bud_prev_label_name' => 'Name',
'bud_adv_title' => 'Advances & Family Tricount',
'bud_adv_add' => 'Add an Advance',
'bud_adv_edit' => 'Edit Advance',
'bud_adv_who_paid' => 'Who paid?',
'bud_adv_payer' => 'Payer',
'bud_adv_has_advanced' => '%s advanced:',
'bud_adv_cc_label' => 'on the joint account',
'bud_adv_livret_label' => 'from their savings account',
'bud_adv_cc_balance_title' => 'Joint Account Balance',
'bud_adv_owed_to' => '<strong>%s €</strong> are owed to <strong>%s</strong>',
'bud_adv_balanced' => 'Accounts are perfectly balanced!',
'bud_adv_saved_badge' => 'Savings',
'bud_adv_already_saved' => 'This money comes from a savings account',
'bud_adv_ph_desc' => 'e.g., Groceries, Electricity bill...',
'bud_adv_confirm_resolve' => 'Confirm the complete reimbursement of this advance?',
'bud_adv_confirm_delete' => 'Permanently delete this advance?',
'date' => 'Date',
'bud_label_name' => 'Description',
'bud_amount' => 'Amount',
'actions' => 'Actions',
'edit' => 'Edit',
'delete' => 'Delete',
'btn_cancel' => 'Cancel',
'btn_save' => 'Save',
'bud_prev_target_ph' => 'E.g., 150',
'bud_prev_transfer_to' => 'To',
// --- Module Budget : Provisions & Optimisation ---
'budget_provisions_title' => 'Provisions & Cashflow Optimization',
'btn_optimize_cashflow' => 'Optimize My Savings',
'add_new_provision' => 'Anticipate an upcoming major expense',
'provision_label' => 'Expense description',
'provision_placeholder_wood' => 'E.g., Firewood order, Car service...',
'amount' => 'Amount',
'expected_date' => 'Expected date',
'btn_add' => 'Add',
'upcoming_provisions_list' => 'Scheduled expenses',
'optimization_assistant_title' => 'Fortnightly Interest Optimization Assistant',
'error_generic' => 'An error occurred',
'success_add_provision' => 'Forecasted expense added successfully!',
'no_provisions' => 'No forecasted expenses registered at the moment.',
'btn_delete' => 'Delete',
'savings_this_month' => 'Total savings to allocate this month',
'calculate' => 'Calculate optimal strategy',
'date_past_warning' => 'The selected date has already passed!',
'budget_tab_provisions' => 'Forecasts',
// ==========================================
// GIFTS
// MODULE: GIFTS (gift-list)
// ==========================================
'gift_page_title' => 'HouseHub - Gift list',
'gift_occ_tio' => 'Tió',
'gift_occ_noel' => 'Christmas',
'gift_occ_rois' => 'Three Kings',
'gift_occ_anniv' => 'Birthday',
'gift_occ_sant' => 'Name day',
'gift_main_title' => 'Gift list %s',
'gift_aria_change_view' => 'Change view',
'gift_view_nadal' => 'Christmas',
'gift_view_anniv' => 'Birthdays',
'gift_view_by_party' => 'View by occasion',
'gift_no_gifts' => 'No gifts recorded for %s in this view.',
'gift_add_gift' => 'Add a gift',
'gift_paid_by' => 'paid by %s',
'gift_summary_title' => 'Budget summary',
'gift_col_adult' => 'Adult',
'gift_col_child' => 'Child',
'gift_col_party' => 'Occasion',
'gift_debtor' => 'Debtor',
'gift_creditor' => 'Creditor',
'gift_liquidations' => 'Settlement',
'gift_no_debt' => 'No outstanding debts.',
'gift_owes' => 'owes',
'gift_to' => 'to',
'gift_detailed_list' => 'Detailed gift list',
'gift_col_gift' => 'Gift',
'gift_col_link' => 'Link',
'gift_modal_title_add' => 'Add a gift for %s',
'gift_modal_title_edit' => 'Edit gift',
// 1. Titles and headers
'gift_page_title' => 'Gift List',
'gift_main_title' => 'Gifts of %s', // %s will be replaced by the year (e.g., Gifts of 2026)
'settings' => 'Settings',
// 2. Filters
'gift_filter_all_children' => 'All children',
'gift_filter_all_adults' => 'All adults',
// 3. Content and empty states
'gift_add_gift' => 'Add',
'gift_empty_state_no_gifts' => 'No gifts recorded for this occasion.',
'gift_empty_state_no_filter' => 'No gifts match your filters.',
'gift_paid_by' => 'Paid by %s', // %s will be replaced by the payer's name
// 4. Tricount Section (Balance & Refunds)
'gift_liquidations' => 'Balance & Refunds',
'gift_no_debt' => 'Everything is settled, no debts!',
'gift_owes' => 'owes',
'gift_view_matrix' => 'View detailed matrix',
'gift_debtor' => 'Debtor',
'gift_creditor' => 'Creditor',
// 5. Modal: Add/Edit Gift
'gift_col_adult' => 'On behalf of',
'gift_modal_payer' => 'Paid by',
'gift_modal_gift_name' => 'Gift name',
'gift_modal_ph_name' => 'e.g. Lego Star Wars',
'gift_modal_price' => 'Price (€)',
'gift_modal_link' => 'Link (optional)',
'gift_confirm_delete' => 'Are you sure you want to delete this gift?',
'gift_filter_all_children' => 'All children',
'gift_filter_all_adults' => 'All adults',
'gift_empty_state_no_gifts' => 'No gifts yet.',
'gift_empty_state_no_filter' => 'No gifts match the filter.',
'gift_view_matrix' => 'View detailed matrix',
'gift_modal_gift_name' => 'Gift description',
'gift_modal_price' => 'Estimated / paid price (€)',
'gift_modal_link' => 'Product link (URL)',
'gift_modal_title_add' => 'Add for %s', // JS: %s = child's name
'gift_modal_title_edit' => 'Edit gift', // JS
'gift_confirm_delete' => 'Are you sure you want to delete this gift?', // JS
// 6. Modal: Occasions Settings
'gift_settings_title' => 'Occasions Settings',
'gift_settings_add_title' => '+ Add an occasion',
'gift_settings_name_placeholder' => 'Name (e.g. Valentine\'s Day)',
'gift_settings_date_placeholder' => 'MM-DD',
'gift_settings_date_help' => 'Date (MM-DD) is optional. Leave empty for a floating date.',
'gift_settings_empty' => 'No occasion configured.', // JS
'gift_settings_active' => 'Active', // JS
// 7. Generic buttons
'btn_cancel' => 'Cancel',
'btn_save' => 'Save',
'btn_add' => 'Add',
'loading' => 'Loading...',
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings' => 'Settings',
'gift_tab_occasions' => 'Occasions & Events',
'gift_tab_proches' => 'Relatives (Outside household)',
'gift_settings_icon' => 'Icon',
'gift_settings_date' => 'Date (MM-DD)',
'gift_settings_add_occ' => 'Add an occasion',
'gift_settings_add_proche'=> 'Add a relative',
'gift_role_proche_adulte'=> 'Adult (Uncle, friend...)',
'gift_role_proche_enfant'=> 'Child (Nephew, niece...)',
'gift_ph_proche_name' => 'First name',
'gift_role_label' => 'Role',
'gift_settings_saved' => 'Settings saved!',
'gift_settings_edit_occ' => 'Edit occasion',
'gift_settings_edit_proche' => 'Edit relative',
// ==========================================
// GARAGE MANAGER
+427 -42
View File
@@ -24,6 +24,7 @@ return [
'menu_budget' => 'Budget',
'menu_holidays' => 'Voyages',
'menu_gifts' => 'Cadeaux',
'menu_calendar_ios' => 'Calendrier iOS',
'nav_home' => 'Accueil',
'nav_calendar' => 'Calendrier',
'nav_budget' => 'Budget',
@@ -93,10 +94,81 @@ return [
'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.',
'mod_garage_name' => 'Garage',
'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Vos mémos, recettes, instructions et infos diverses avec Markdown et pièces jointes.',
'menu_memo' => 'Notes',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Gérez vos tâches, listes de courses et rappels en famille.',
'mod_calendar_ios_name' => 'Calendrier iOS',
'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).',
'menu_todo' => 'Todo',
'mod_groceries_name' => 'Courses',
'mod_groceries_desc' => 'Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.',
'menu_groceries' => 'Courses',
// Module Liste (remplace Courses)
'mod_liste_name' => 'Listes',
'mod_liste_desc' => 'Listes partagées nommées — courses, bricolage, pharmacie… Autant de listes que nécessaire.',
'menu_liste' => 'Listes',
'liste_placeholder_add' => 'Ajouter un article…',
'liste_btn_add' => 'Ajouter',
'liste_btn_uncheck_all' => 'Tout décocher',
'liste_btn_delete_picked' => 'Supprimer les cochés',
'liste_btn_clear_all' => 'Vider la liste',
'liste_subtitle' => '{pending} à faire · {cart} cochés',
'liste_added' => 'Ajouté',
'liste_updated' => 'Modifié',
'liste_deleted' => 'Supprimé',
'liste_already_in' => 'Déjà dans la liste',
'liste_confirm_clear' => 'Vider toute la liste ?',
'liste_confirm_delete_list' => 'Supprimer cette liste et tous ses articles ?',
'liste_history_title' => 'Récemment utilisés — clic pour rajouter',
'liste_new_list' => 'Nouvelle liste',
'liste_list_name_placeholder'=> 'Nom de la liste…',
'liste_create' => 'Créer',
'liste_rename_list' => 'Renommer',
'liste_new_name' => 'Nouveau nom :',
'liste_list_deleted' => 'Liste supprimée',
'liste_list_created' => 'Liste créée',
'liste_list_renamed' => 'Liste renommée',
'liste_edit_placeholder' => 'Modifier l\'article :',
'liste_empty_state' => 'Créez votre première liste avec le bouton + ci-dessus.',
'liste_settings_title' => 'Listes — historique',
'liste_settings_intro' => 'Nombre maximum d\'articles mémorisés dans l\'historique (commun à toutes les listes).',
'liste_settings_history_max' => 'Taille de l\'historique',
'liste_settings_history_hint'=> 'Entre 1 et 50.',
'groceries_settings_saved' => 'Préférences enregistrées.',
'groceries_settings_error' => 'Impossible d\'enregistrer :',
'groceries_err_duplicate' => 'Ce produit est déjà dans la liste.',
'cta_open' => 'Ouvrir',
'cta_explore' => 'Explorer',
'cta_view_lists' => 'Voir les listes',
'cta_view_lists' => 'Déballer',
'cta_manage' => 'Gérer',
'cta_service' => 'Réviser',
'cta_jot' => 'Griffonner',
'cta_check' => 'Cocher',
'cta_cart' => 'Faire les courses',
'cta_sync' => 'Synchroniser',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Gérez vos fichiers d\'impression 3D — STL, 3MF, GCode avec viewer interactif.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Imprimer',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Gérez vos projets en mode Kanban avec Planka.',
'menu_planka' => 'Planka',
// --- SETTINGS : ENFANTS ---
'set_kids_title' => 'Enfants du foyer',
'set_kids_desc' => 'Gérez la liste des enfants pour les associer à l\'épargne, aux cadeaux, etc.',
'set_add_kid_placeholder' => 'Prénom de l\'enfant',
'set_btn_add_kid' => 'Ajouter',
'set_err_kid_name' => 'Le prénom est requis.',
'set_confirm_del_kid' => 'Supprimer cet enfant ? (Son historique financier restera visible dans les archives).',
'set_no_kids' => 'Aucun enfant configuré pour ce foyer.',
'set_family_config' => 'Configuration du foyer',
'set_global_params' => 'Paramètres globaux',
'set_care_modes' => 'Modes de garde usuels',
'set_add_care_mode' => 'Ajouter un mode',
'btn_save_config' => 'Enregistrer',
// ==========================================
// FAMILY CALENDAR
@@ -105,7 +177,7 @@ return [
'fc_main_header' => 'Calendrier Familial',
'fc_btn_correct_balances' => 'Corriger les soldes',
'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_col_period' => 'Période',
'fc_col_from' => 'Du',
@@ -141,6 +213,126 @@ return [
'fc_err_action' => 'Erreur lors de l\'action : ',
'fc_today_short' => 'Auj.',
'fc_today_title' => "Aujourd'hui",
'care_nounou' => 'Nounou',
'care_centre' => 'Centre de loisirs',
'care_avis' => 'Avis',
'fc_tab_foyer' => '🏡 Foyer',
'fc_tab_members' => '👥 Membres & Droits',
'fc_select_member' => 'Sélectionner un membre de la famille :',
'fc_role_adult' => 'Adulte',
'fc_role_child' => 'Enfant',
'fc_care_modes_title' => 'Modes de garde associés',
'fc_care_modes_desc' => 'Cochez les modes de garde qui concernent cet enfant.',
'fc_no_care_mode' => 'Aucun mode configuré.',
'fc_matrix_title' => '👨‍👩‍👦 Matrice des Droits aux Congés',
'fc_matrix_desc' => 'Déclarez les codes de compteurs et dates de renouvellement.',
'fc_matrix_code' => 'Code',
'fc_matrix_date' => 'Date Reset',
'fc_matrix_empty' => 'Aucun compteur configuré.',
'fc_matrix_saved' => 'Matrice des droits mise à jour ! 💼',
'fc_child_saved' => 'Abonnement enfant enregistré !',
'btn_add_counter' => ' Ajouter',
'btn_save_rights' => '💾 Enregistrer',
'fc_modal_settings_title' => 'Paramètres du Calendrier',
'fc_zone_label' => 'Zone Scolaire (Vacances)',
'fc_zone_desc' => 'Détermine le calendrier automatique des vacances scolaires.',
'fc_care_modes_label' => 'Modes de Garde du Foyer',
'fc_care_modes_desc' => 'Gérez la liste globale des modes de garde possibles pour la famille.',
'fc_add_mode_placeholder' => 'Ex: Nounou, Casal, Grands-parents...',
'fc_settings_updated' => 'Paramètres mis à jour avec succès !',
'fc_first_day' => 'Premier jour de la semaine',
// --- CALENDRIER : PARAMÈTRES (MEMBRES & AFFICHAGE) ---
'fc_tab_display' => '🖥️ Affichage',
'fc_display_title' => 'Paramètres d\'affichage',
'fc_display_desc' => 'Personnalisez l\'apparence et le comportement par défaut de votre calendrier.',
'fc_default_view' => 'Vue par défaut',
'fc_view_month' => '1 mois',
'fc_view_2months' => '2 mois',
'fc_view_3months' => '3 mois',
'fc_working_hours' => 'Horaires de garde (Défaut)',
'fc_working_hours_desc' => 'Exemple : 08:30-18:00',
'fc_role_unknown' => 'Inconnu',
'fc_role_adult' => 'Adulte',
'fc_role_child' => 'Enfant',
'fc_role_helper' => 'Intervenant',
'fc_error' => 'Erreur : ',
'fc_care_modes_title' => 'Modes de Garde Par Défaut',
'fc_care_modes_desc' => 'Quels modes de garde s\'appliquent à cet enfant ?',
'fc_no_care_mode' => 'Aucun mode de garde configuré dans le foyer.',
'fc_child_saved' => 'Sauvegardé !',
'fc_matrix_title' => 'Matrice des Congés',
'fc_matrix_desc' => 'Définissez les quotas et la date de renouvellement.',
'fc_matrix_empty' => 'Aucun compteur défini.',
'fc_matrix_saved' => 'Matrice enregistrée !',
'fc_leave_obsolete' => 'Obsolète',
'fc_leave_code_placeholder' => 'Code...',
'fc_leave_method_fixed' => 'Fixe',
'fc_leave_method_accumulated' => 'Cumul',
'fc_date_format_placeholder' => 'JJ/MM',
'fc_date_format_title' => 'Format JJ/MM',
'fc_table_code' => 'Code',
'fc_table_method' => 'Méthode',
'fc_table_quota' => 'Quota',
'fc_table_renewal' => 'Renouv.',
'btn_save_rights' => 'Enregistrer',
// ==========================================
// MODALE PARAMÈTRES CALENDRIER
// ==========================================
'fc_modal_settings_title' => 'Paramètres du calendrier',
'fc_tab_foyer' => 'Foyer',
'fc_tab_members' => 'Membres & Droits',
// Foyer : Zone scolaire
'fc_zone_label' => 'Zone scolaire',
'fc_zone_a' => 'Zone A',
'fc_zone_b' => 'Zone B',
'fc_zone_c' => 'Zone C',
'fc_zone_other' => 'Autre / Hors France',
'fc_zone_desc' => 'Permet d\'afficher correctement les vacances scolaires.',
// Foyer : Modes de garde
'fc_care_modes_label' => 'Modes de garde',
'fc_add_mode_placeholder' => 'Ex: Nounou, Crèche...',
'fc_care_modes_desc' => 'Ajoutez les différents modes de garde pour vos enfants.',
'fc_empty_kids_msg' => 'Aucun enfant n\'est configuré dans votre famille pour le moment.',
'fc_link_settings' => 'Aller aux paramètres généraux',
// Foyer : Catalogue des congés
'fc_leave_catalog_title' => 'Catalogue des congés',
'fc_leave_catalog_desc' => 'Définissez les types de congés possibles pour toute la famille.',
'fc_add_leave_type' => 'Ajouter un type de congé',
'fc_col_code' => 'Code',
'fc_code_irreversible' => 'Irréversible',
'fc_col_label' => 'Label',
'fc_leave_label_ph' => 'Ex: RTT, Récup...',
'fc_quota_default' => 'Quota par défaut',
'fc_reset_month' => 'Mois de renouv.',
// Membres
'fc_select_member' => 'Sélectionner un membre :',
// Mois (S'ils n'existent pas déjà dans ton système)
'month_jan' => 'Janvier',
'month_feb' => 'Février',
'month_mar' => 'Mars',
'month_apr' => 'Avril',
'month_may' => 'Mai',
'month_jun' => 'Juin',
'month_jul' => 'Juillet',
'month_aug' => 'Août',
'month_sep' => 'Septembre',
'month_oct' => 'Octobre',
'month_nov' => 'Novembre',
'month_dec' => 'Décembre',
'leg_presence' => 'Présence Pep',
'leg_school_holidays' => 'Vacances Scolaires',
@@ -327,6 +519,71 @@ return [
'cat_fixed' => 'Charges Fixes',
'cat_others' => 'Autres / Imprévus',
'cat_savings' => 'Épargne',
'budget_tab_kids' => 'Enfants',
'budget_settings_title' => 'Paramètres du Budget',
'bs_tab_accounts' => '💳 Comptes',
'bs_tab_categories' => '🏷️ Catégories',
'bs_tab_rules' => '🤖 Règles d\'import',
'bs_tab_salaries' => '💼 Salaires',
'bs_empty_accounts' => 'Aucun compte configuré.',
'bs_type_checking' => 'Courant',
'bs_type_savings' => 'Épargne',
'bs_badge_default' => 'Défaut',
'bs_owner' => 'Propriétaire',
'bs_shared_account' => 'Compte Commun',
'bs_empty_categories' => 'Aucune catégorie configurée.',
'bs_type_income' => 'Revenu',
'bs_type_expense' => 'Dépense',
'bs_type_savings_cat' => 'Épargne',
'bs_empty_rules' => 'Aucune règle d\'importation.',
'bs_empty_salaries' => 'Aucun salaire défini pour l\'année',
'bs_salaries_year_desc' => 'Configurations pour l\'année',
'bs_salary_net' => 'Salaire net',
'bs_salary_fees' => 'Mensualité / Frais',
'bs_add_account_title' => '+ Ajouter un compte',
'bs_account_name_ph' => 'Nom (ex: Livret A Bébé)',
'bs_confirm_delete' => 'Voulez-vous vraiment supprimer ce compte ?',
'bs_add_category_title' => '+ Ajouter une catégorie',
'bs_cat_code_ph' => 'Code (ex: FOOD)',
'bs_cat_label_ph' => 'Libellé (ex: Courses)',
'bs_cat_icon_ph' => 'Icône (ex: 🛒)',
'bs_confirm_delete_cat' => 'Voulez-vous vraiment supprimer cette catégorie ?',
'bs_add_rule_title' => 'Ajouter une règle',
'bs_rule_keyword_ph' => 'Mot-clé (ex: AUCHAN)',
'bs_confirm_delete_rule' => 'Voulez-vous vraiment supprimer cette règle ?',
'bs_currency_label' => 'Devise du Foyer',
'bs_currency_desc' => 'Devise principale utilisée pour l\'affichage de vos comptes et budgets.',
'bs_currency_updated' => 'Devise mise à jour avec succès !',
// ONGLET CSV MAPPING
'bs_csv_title' => 'Format de l\'import CSV',
'bs_csv_desc' => 'Configurez ici les colonnes correspondantes à l\'export de votre banque.',
'bs_csv_delimiter' => 'Séparateur',
'bs_csv_delim_semi' => 'Point-virgule (;)',
'bs_csv_delim_comma' => 'Virgule (,)',
'bs_csv_delim_tab' => 'Tabulation',
'bs_csv_date_format' => 'Format de la Date',
'bs_csv_col_index' => 'Index des colonnes',
'bs_csv_col_index_help' => '(Attention : la 1ère colonne est la Numéro 0)',
'bs_csv_col_date' => 'N° Col : Date',
'bs_csv_col_label' => 'N° Col : Libellé / Motif',
'bs_csv_amount_mgmt' => 'Gestion des montants',
'bs_csv_amount_single' => 'Une seule colonne (Positif = Revenu, Négatif = Dépense)',
'bs_csv_amount_split' => 'Deux colonnes séparées (Débit / Crédit)',
'bs_csv_col_amount' => 'N° Col : Montant',
'bs_csv_col_debit' => 'N° Col : Débit',
'bs_csv_col_credit' => 'N° Col : Crédit',
'bs_csv_col_ref' => 'N° Col : Référence unique (Optionnel)',
'btn_save_format' => 'Enregistrer le format',
'bs_csv_saved' => 'Format CSV enregistré',
'bs_tab_csv' => '📥 Import CSV',
'bs_csv_settings_title' => 'Configuration du format',
'bs_csv_drop_title' => 'Glissez votre fichier CSV ici',
'bs_csv_drop_desc' => 'ou cliquez pour parcourir vos fichiers',
'bs_csv_err_type' => 'Erreur : Seuls les fichiers .csv sont autorisés.',
'bs_csv_file_ok' => 'Fichier chargé',
'bs_csv_preview_title' => 'Aperçu de vos colonnes',
'bs_csv_empty_col' => 'Vide',
'bs_csv_no_data' => 'Aucune ligne de transaction trouvée dans ce fichier.',
// --- BUDGET : SUIVI ---
'bud_rem_school' => 'École (Reste estimé)',
@@ -395,6 +652,25 @@ return [
'bud_to_define' => "À définir",
'bud_is_charge' => "Est une charge fixe...",
'bud_is_income' => "Est un revenu...",
'bud_edit_title' => 'Modifier la transaction',
'bud_to_define_js' => 'à définir',
'bud_select_beneficiary' => 'Choisir un revenu attendu',
'bud_bar_actual' => 'Actuel',
// ONBOARDING BUDGET
'bs_onb_title' => 'Bienvenue ! Configurons votre budget :',
'bs_onb_desc' => 'Complétez ces étapes pour que l\'application puisse calculer votre Reste à Vivre :',
'bs_onb_cat_link' => 'Catégories',
'bs_onb_cat_desc' => 'Créez au moins une catégorie de dépenses.',
'bs_onb_acc_link' => 'Comptes Bancaires',
'bs_onb_acc_desc' => 'Ajoutez au moins un compte courant ou épargne.',
'bs_onb_sal_link' => 'Salaires',
'bs_onb_sal_desc' => 'Renseignez les rentrées d\'argent de l\'année en cours.',
'bs_onb_items_link' => 'Lignes de Budget',
'bs_onb_items_desc' => 'Rendez-vous dans l\'onglet Récapitulatif pour lister vos charges fixes (Loyer, Assurances...).',
'bs_rule_search_ph' => '🔍 Rechercher un mot-clé (ex: AUCHAN)...',
'error_loading_settings' => 'Erreur lors du chargement des paramètres',
'settings' => 'Paramètres',
// --- BUDGET : RECAP ---
'bud_recap_monthly_title' => 'Récapitulatif Mensuel',
@@ -437,6 +713,10 @@ return [
'bud_reg_none' => 'Aucune',
'bud_recap_confirm_delete' => 'Voulez-vous vraiment supprimer cet élément ?',
'bud_err_delete' => 'Erreur lors de la suppression.',
'bud_prev_monthly_target' => 'Montant cible / Mensualité (€)',
'bud_prev_transfer_dest' => 'Destination du virement',
'bud_prev_none' => 'Aucune destination (Charge standard)',
// --- BUDGET : EPARGNE ---
'bud_sav_add_month' => 'Saisir un mois',
@@ -467,6 +747,68 @@ return [
'bud_sav_prompt_duplicate' => "Voulez-vous copier les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
'bud_err_server' => 'Erreur serveur : ',
'bud_err_network_dup' => 'Erreur réseau lors de la duplication.',
'budget_tab_kids' => 'Enfants 👶',
'bud_sav_add_one_month' => '+1 Mois',
'bud_sav_add_month' => 'Ajouter un mois',
'bud_sav_no_data' => 'Aucune donnée d\'épargne pour %s.',
'bud_sav_post_month' => 'Poste / Mois',
'bud_sav_from_date' => 'Dès le %s',
'bud_sav_edit_modal' => 'Éditer ce mois',
'bud_sav_delete_month' => 'Supprimer ce mois',
'bud_sav_total_bank' => 'TOTAL BANQUE',
'bud_sav_extra' => 'NON ALLOUÉ (EXTRAS)',
'bud_sav_modal_title_add' => 'Ajouter un mois',
'bud_sav_modal_title_edit' => 'Éditer',
'bud_sav_month_concerned' => 'Mois concerné',
'bud_sav_total_bank_eur' => 'Total en Banque (€)',
'bud_sav_ventilation' => 'Ventilation des comptes',
'bud_sav_adj_help' => 'Saisissez les ajustements (+/-) pour recalculer automatiquement.',
'bud_sav_add_line' => 'Ajouter une ligne',
'bud_category' => 'Catégorie',
'bud_sav_current' => 'Actuel',
'bud_sav_adjust' => 'Ajustement',
'bud_sav_new' => 'Nouveau',
'bud_sav_sum_mode_title' => 'Activer/Désactiver la calculatrice',
'bud_sav_selection' => 'Sélection :',
'bud_sav_ph_name' => 'Nom du compte',
// --- BUDGET : PREVISION ---
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
'btn_optimize_cashflow' => 'Optimiser mes Économies',
'add_new_provision' => 'Anticiper une grosse dépense à venir',
'provision_label' => 'Libellé de la dépense',
'provision_placeholder_wood' => 'Ex: Commande bois de chauffage, Révision voiture...',
'amount' => 'Montant',
'expected_date' => 'Date prévue',
'btn_add' => 'Ajouter',
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
'error_generic' => 'Une erreur est survenue',
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
'btn_delete' => 'Supprimer',
'savings_this_month' => 'Économies globales à placer ce mois-ci',
'calculate' => 'Calculer la stratégie optimale',
'date_past_warning' => 'La date sélectionnée est déjà passée !',
'budget_tab_provisions' => 'Provisions',
'budget_opti_base' => 'Base',
'budget_opti_debt' => 'Dette compensée',
'budget_opti_no_config' => 'Aucune configuration de salaire trouvée pour cette année.',
'budget_opti_recap_apports' => 'Résumé des apports (Clearing) :',
'budget_opti_total_disp' => 'Total disponible pour l\'optimisation :',
'budget_opti_must_pay' => 'doit verser',
// JS & Erreurs
'bud_sav_confirm_delete_month' => 'Voulez-vous vraiment supprimer toutes les données de %m pour %o ?',
'bud_sav_prompt_duplicate' => "Voulez-vous dupliquer les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
'bud_err_tech' => 'Une erreur technique est survenue.',
'bud_err_server' => 'Erreur serveur : ',
'bud_err_network_dup' => 'Erreur réseau lors de la duplication.',
'bud_sav_saving' => 'Sauvegarde en cours...',
'bud_err_delete' => 'Erreur lors de la suppression.',
// --- BUDGET : PREVISIONNEL ---
'bud_prev_incomes' => 'Revenus',
@@ -505,50 +847,93 @@ return [
'bud_sav_modal_title_add' => 'Saisir un mois',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_prev_label_name' => 'Nom',
'bud_adv_title' => 'Avances & Tricount Familial',
'bud_adv_add' => 'Ajouter une avance',
'bud_adv_edit' => 'Modifier l\'avance',
'bud_adv_who_paid' => 'Qui a payé ?',
'bud_adv_payer' => 'Payeur',
'bud_adv_has_advanced' => '%s a avancé :',
'bud_adv_cc_label' => 'sur le compte commun',
'bud_adv_livret_label' => 'depuis son livret d\'épargne',
'bud_adv_cc_balance_title' => 'Équilibre du Compte Commun',
'bud_adv_owed_to' => '<strong>%s €</strong> sont dus à <strong>%s</strong>',
'bud_adv_balanced' => 'Les comptes sont parfaitement équilibrés !',
'bud_adv_saved_badge' => 'Épargne',
'bud_adv_already_saved' => 'Cet argent provient d\'un livret d\'épargne',
'bud_adv_ph_desc' => 'Ex: Courses, Facture Électricité...',
'bud_adv_confirm_resolve' => 'Confirmer le remboursement complet de cette avance ?',
'bud_adv_confirm_delete' => 'Supprimer définitivement cette avance ?',
'bud_prev_target_ph' => 'Ex: 150',
'bud_prev_transfer_to' => 'Vers',
// ==========================================
// CADEAUX (GIFTS)
// MODULE : CADEAUX (gift-list)
// ==========================================
'gift_page_title' => 'HouseHub - Liste de cadeaux',
'gift_occ_tio' => 'Tió',
'gift_occ_noel' => 'Noël',
'gift_occ_rois' => 'Rois mages',
'gift_occ_anniv' => 'Anniversaire',
'gift_occ_sant' => 'Saint',
'gift_main_title' => 'Liste de cadeaux %s',
'gift_aria_change_view' => 'Changer la vue',
'gift_view_nadal' => 'Noël',
'gift_view_anniv' => 'Anniversaires',
'gift_view_by_party' => 'Vue par fête',
'gift_no_gifts' => 'Aucun cadeau enregistré pour %s dans cette vue.',
'gift_add_gift' => 'Ajouter un cadeau',
'gift_paid_by' => 'payé par %s',
'gift_summary_title' => 'Résumé du budget',
'gift_col_adult' => 'Adulte',
'gift_col_child' => 'Enfant',
'gift_col_party' => 'Fête',
'gift_debtor' => 'Débiteur',
'gift_creditor' => 'Créancier',
'gift_liquidations' => 'Tricount',
'gift_no_debt' => 'Aucune dette en cours.',
'gift_owes' => 'doit',
'gift_to' => 'à',
'gift_detailed_list' => 'Liste détaillée des cadeaux',
'gift_col_gift' => 'Cadeau',
'gift_col_link' => 'Lien',
'gift_modal_title_add' => 'Ajouter un cadeau pour %s',
'gift_modal_title_edit' => 'Modifier le cadeau',
// 1. Titres et en-têtes
'gift_page_title' => 'Liste des Cadeaux',
'gift_main_title' => 'Cadeaux de %s', // %s sera remplacé par l'année (ex: Cadeaux de 2026)
'settings' => 'Paramètres',
// 2. Filtres
'gift_filter_all_children' => 'Tous les enfants',
'gift_filter_all_adults' => 'Tous les adultes',
// 3. Contenu et états vides
'gift_add_gift' => 'Ajouter',
'gift_empty_state_no_gifts' => 'Aucun cadeau enregistré pour cette fête.',
'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond à vos filtres.',
'gift_paid_by' => 'Payé par %s', // %s sera remplacé par le nom du payeur
// 4. Section Tricount (Bilan & Remboursements)
'gift_liquidations' => 'Bilan & Remboursements',
'gift_no_debt' => 'Tout est à jour, aucune dette !',
'gift_owes' => 'doit',
'gift_view_matrix' => 'Voir la matrice détaillée',
'gift_debtor' => 'Débiteur',
'gift_creditor' => 'Créancier',
// 5. Modale : Ajout/Modification de Cadeau
'gift_col_adult' => 'Pour le compte de',
'gift_modal_payer' => 'Payé par',
'gift_modal_gift_name' => 'Nom du cadeau',
'gift_modal_ph_name' => 'ex: Lego Star Wars',
'gift_modal_price' => 'Prix (€)',
'gift_modal_link' => 'Lien (optionnel)',
'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?',
'gift_filter_all_children' => 'Tous les enfants',
'gift_filter_all_adults' => 'Tous les adultes',
'gift_empty_state_no_gifts' => 'Aucun cadeau pour le moment.',
'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond au filtre.',
'gift_view_matrix' => 'Voir la matrice détaillée',
'gift_modal_gift_name' => 'Description du cadeau',
'gift_modal_price' => 'Prix estimé / payé (€)',
'gift_modal_link' => 'Lien du produit (URL)',
'gift_modal_title_add' => 'Ajouter pour %s', // JS : %s = nom de l'enfant
'gift_modal_title_edit' => 'Modifier le cadeau', // JS
'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?', // JS
// 6. Modale : Paramètres des Fêtes (Nouveau !)
'gift_settings_title' => 'Configuration des Fêtes',
'gift_settings_add_title' => '+ Ajouter une occasion',
'gift_settings_name_placeholder' => 'Nom (ex: Saint Valentin)',
'gift_settings_date_placeholder' => 'MM-JJ',
'gift_settings_date_help' => 'La date (MM-JJ) est optionnelle. Laissez vide pour une fête mobile.',
'gift_settings_empty' => 'Aucune fête configurée.', // JS
'gift_settings_active' => 'Actif', // JS
// 7. Boutons génériques (si tu ne les as pas déjà en global)
'btn_cancel' => 'Annuler',
'btn_save' => 'Enregistrer',
'btn_add' => 'Ajouter',
'loading' => 'Chargement...',
// --- MODULE CADEAUX : SETTINGS ---
'gift_settings' => 'Configuration',
'gift_tab_occasions' => 'Fêtes & Occasions',
'gift_tab_proches' => 'Proches (Hors foyer)',
'gift_settings_icon' => 'Icône',
'gift_settings_date' => 'Date (MM-JJ)',
'gift_settings_add_occ' => 'Ajouter une fête',
'gift_settings_add_proche'=> 'Ajouter un proche',
'gift_role_proche_adulte'=> 'Adulte (Oncle, ami...)',
'gift_role_proche_enfant'=> 'Enfant (Neveu, nièce...)',
'gift_ph_proche_name' => 'Prénom',
'gift_role_label' => 'Rôle',
'gift_settings_saved' => 'Paramètres enregistrés !',
'gift_settings_edit_occ' => 'Modifier la fête',
'gift_settings_edit_proche' => 'Modifier le proche',
// ==========================================
// GARAGE MANAGER
// ==========================================
+20 -4
View File
@@ -1,9 +1,23 @@
<?php
// includes/meta_db.php
// Connexion à la base meta (gestion des familles et utilisateurs)
$meta_host = getenv('DB_HOST') ?: 'househub-db';
$meta_db = 'househub_meta';
$meta_user = getenv('DB_USER') ?: 'househub';
$meta_pass = getenv('DB_PASS') ?: 'changeme';
$envHost = getenv('DB_HOST');
// Détection stricte : Docker injecte toujours 'househub-db' via le docker-compose
if ($envHost === 'househub-db') {
// Environnement Docker (Ton collègue ou la Prod)
$meta_host = 'househub-db';
$meta_user = getenv('DB_USER') ?: 'househub';
$meta_pass = getenv('DB_PASS') ?: 'changeme';
} else {
// Environnement Local XAMPP (Toi)
$meta_host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
$meta_user = 'root';
$meta_pass = '';
}
$meta_db = 'househub_meta';
try {
$meta_pdo = new PDO(
@@ -14,8 +28,10 @@ try {
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
]
);
} catch (\PDOException $e) {
die(json_encode(['error' => 'Meta DB unavailable: ' . $e->getMessage()]));
}
?>
+47 -2
View File
@@ -43,7 +43,7 @@ if ($_has_custom_bg): ?>
<section class="pf-section">
<h2 style="color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.6);"><?= tr('home_modules_title') ?></h2>
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','garage']; ?>
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','garage','calendar_ios']; ?>
<div class="pf-modules-grid">
<?php if (in_array('calendar', $mods)): ?>
@@ -87,7 +87,52 @@ if ($_has_custom_bg): ?>
<div class="pf-card-icon">🚗</div>
<h3 class="pf-card-title"><?= tr('mod_garage_name') ?></h3>
<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('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('liste', $mods)): ?>
<a href="/liste.php" class="pf-module-card">
<div class="pf-card-icon">🛒</div>
<h3 class="pf-card-title"><?= tr('mod_liste_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_liste_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_cart') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?>
<a href="/calendar-ios.php" class="pf-module-card">
<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>
<?php endif; ?>
+62
View File
@@ -0,0 +1,62 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = tr('mod_liste_name') . ' — HouseHub';
$activePage = 'liste';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/liste/assets/liste.css">
<main class="liste-main">
<!-- Tabs des listes -->
<div class="liste-tabs-bar">
<div class="liste-tabs" id="liste-tabs"></div>
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
</div>
<!-- Corps de la liste active -->
<div class="liste-body" id="liste-body">
<div class="liste-add-card">
<input type="text" id="liste-input" maxlength="500"
placeholder="<?= htmlspecialchars(tr('liste_placeholder_add')) ?>" autocomplete="off">
<button id="btn-liste-add"><?= htmlspecialchars(tr('liste_btn_add')) ?></button>
</div>
<div class="liste-toolbar" id="liste-toolbar">
<button class="btn-tool" id="btn-uncheck-all"><?= htmlspecialchars(tr('liste_btn_uncheck_all')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-delete-picked"><?= htmlspecialchars(tr('liste_btn_delete_picked')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-clear-all"><?= htmlspecialchars(tr('liste_btn_clear_all')) ?></button>
</div>
<div id="liste-items-root"></div>
<div id="liste-history-root"></div>
</div>
</main>
<script>
const LISTE_TRANSLATIONS = {
already_in: <?= json_encode(tr('liste_already_in')) ?>,
confirm_clear: <?= json_encode(tr('liste_confirm_clear')) ?>,
confirm_delete_list: <?= json_encode(tr('liste_confirm_delete_list')) ?>,
rename_list: <?= json_encode(tr('liste_rename_list')) ?>,
new_name: <?= json_encode(tr('liste_new_name')) ?>,
list_deleted: <?= json_encode(tr('liste_list_deleted')) ?>,
list_created: <?= json_encode(tr('liste_list_created')) ?>,
list_renamed: <?= json_encode(tr('liste_list_renamed')) ?>,
edit_placeholder: <?= json_encode(tr('liste_edit_placeholder')) ?>,
history_title: <?= json_encode(tr('liste_history_title')) ?>,
added: <?= json_encode(tr('liste_added')) ?>,
updated: <?= json_encode(tr('liste_updated')) ?>,
deleted: <?= json_encode(tr('liste_deleted')) ?>,
cancel: <?= json_encode(tr('cancel')) ?>,
};
</script>
<script src="/modules/liste/assets/liste.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+8 -1
View File
@@ -2,6 +2,7 @@
session_start();
require_once __DIR__ . '/includes/i18n.php';
require_once __DIR__ . '/includes/meta_db.php';
require_once __DIR__ . '/includes/auth.php';
$pageTitle = tr('login_title');
$activePage = "login";
@@ -14,6 +15,9 @@ if (isset($_SESSION['user'])) {
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
$error = "Session invalide (CSRF). Rechargez la page.";
} else {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
@@ -36,6 +40,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} elseif ($user['family_id'] && !$user['family_active']) {
$error = "Espace familial désactivé. Contactez l'administrateur.";
} else {
session_regenerate_id(true);
$_SESSION['user'] = [
'id' => (int)$user['id'],
'username' => $user['username'],
@@ -45,7 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
];
$_SESSION['family_db'] = $user['db_name'];
$_SESSION['app_lang'] = $user['lang'] ?? 'fr';
$_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts"]', true);
$_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts","calendar_ios"]', true);
$redirectTo = $_GET['redirect'] ?? '/index.php';
header('Location: ' . $redirectTo);
exit;
@@ -54,6 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$error = tr('error_invalid_credentials');
}
}
}
}
require __DIR__ . '/header.php';
@@ -75,6 +81,7 @@ require __DIR__ . '/header.php';
<?php endif; ?>
<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">
<label class="pf-label" for="username"><?= tr('label_username') ?></label>
<input type="text" id="username" name="username" class="pf-input"
+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'; ?>
+48
View File
@@ -0,0 +1,48 @@
<?php
require_once __DIR__ . '/includes/meta_db.php';
// Récupération des identifiants de BDD depuis l'environnement (fallback par défaut)
$db_host = getenv('DB_HOST') ?: 'househub-db';
$db_user = getenv('DB_USER') ?: 'househub';
$db_pass = getenv('DB_PASS') ?: 'changeme';
echo "<h1>🛠️ HouseHub - Correction AUTO_INCREMENT Voyages</h1>";
try {
// On récupère toutes les familles actives
$stmt = $meta_pdo->query("SELECT db_name, name FROM families");
$families = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($families as $f) {
$dbName = $f['db_name'];
echo "<h3>Famille : {$f['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
]);
// 1. On vérifie si la clé primaire existe déjà pour éviter de faire planter le script
$hasPrimaryKey = $pdo->query("SHOW KEYS FROM pf_holidays WHERE Key_name = 'PRIMARY'")->fetch();
if (!$hasPrimaryKey) {
$pdo->exec("ALTER TABLE pf_holidays ADD PRIMARY KEY (id)");
echo "<li>✅ Clé primaire restaurée sur la colonne `id`.</li>";
}
// 2. On restaure la propriété AUTO_INCREMENT
$pdo->exec("ALTER TABLE pf_holidays MODIFY id INT(11) NOT NULL AUTO_INCREMENT");
echo "<li>✅ Propriété AUTO_INCREMENT restaurée.</li>";
} catch (\PDOException $e) {
// On attrape l'erreur si la modif a déjà été faite
echo "<li>️ Action ignorée ou table déjà à jour : " . $e->getMessage() . "</li>";
}
echo "</ul>";
}
echo "<h2>🚀 Correction terminée ! Tu peux réessayer de créer un voyage.</h2>";
} catch (Exception $e) {
die("Erreur fatale de connexion à la Meta DB : " . $e->getMessage());
}
?>
+49
View File
@@ -0,0 +1,49 @@
<?php
// migrate_attachments.php
// 1. Inclusion de ta connexion BDD principale (Ajuste le chemin si besoin)
require __DIR__ . '/includes/db.php';
echo "<pre>🚀 Lancement de la migration multi-bases...\n\n";
try {
// 2. Récupérer toutes les bases de données qui commencent par "househub_f"
$stmt = $pdo->query("SHOW DATABASES LIKE 'househub_f%'");
$databases = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($databases)) {
die("❌ Aucune base de données familiale trouvée (Format househub_f1, househub_f2...).\n");
}
$successCount = 0;
foreach ($databases as $dbName) {
// Sécurité : on vérifie le format exact (househub_f suivi de chiffres)
if (preg_match('/^househub_f\d+$/', $dbName)) {
echo "⚙️ Mise à jour de la base : <strong>$dbName</strong>... ";
// On bascule la connexion sur la base de la famille
$pdo->exec("USE `$dbName`");
// 3. Création de la table
$sql = "CREATE TABLE IF NOT EXISTS pf_holidays_attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
holiday_id INT NOT NULL,
item_id INT DEFAULT NULL,
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(255) NOT NULL,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_holiday_attachment FOREIGN KEY (holiday_id) REFERENCES pf_holidays(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$pdo->exec($sql);
echo "✅ OK\n";
$successCount++;
}
}
echo "\n🎉 Migration terminée avec succès sur $successCount base(s) familiale(s) !</pre>";
} catch (PDOException $e) {
die("\n❌ Erreur SQL pendant la migration : " . $e->getMessage() . "</pre>");
}
+64
View File
@@ -0,0 +1,64 @@
<?php
// migrate_categories_order.php
// ============================================================================
// CONFIGURATION DE CONNEXION (À ADAPTER)
// ============================================================================
require __DIR__ . '/includes/db.php';
echo "<h2>🚀 Migration : Ajout de l'ordre des catégories</h2>";
try {
// Connexion au serveur MySQL (sans spécifier de base de données particulière)
$pdo = new PDO("mysql:host=$host;charset=utf8mb4", $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 1. Trouver toutes les bases de données de la famille HouseHub
$stmt = $pdo->query("SHOW DATABASES LIKE 'househub_f%'");
$databases = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($databases)) {
echo "<p style='color: #ef4444;'>❌ Aucune base de données correspondant à 'househub_f*' n'a été trouvée.</p>";
exit;
}
echo "<p>🔍 <strong>" . count($databases) . "</strong> base(s) de données trouvée(s).</p>";
// 2. Boucler sur chaque base de données
foreach ($databases as $db) {
echo "<hr><div style='padding: 10px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 10px;'>";
echo "<h4 style='margin-top: 0;'>📂 Traitement de la base : <strong>$db</strong></h4>";
// Sélectionner la base de données active
$pdo->exec("USE `$db`");
// Vérifier si la table pf_budget_categories existe dans cette DB
$tableExists = $pdo->query("SHOW TABLES LIKE 'pf_budget_categories'")->rowCount() > 0;
if ($tableExists) {
// Vérifier si la colonne sort_order existe déjà (sécurité)
$columnExists = $pdo->query("SHOW COLUMNS FROM pf_budget_categories LIKE 'sort_order'")->rowCount() > 0;
if (!$columnExists) {
// La colonne n'existe pas, on l'ajoute !
$pdo->exec("ALTER TABLE pf_budget_categories ADD sort_order INT DEFAULT 0");
echo "<span style='color: #10b981;'>✅ Colonne 'sort_order' ajoutée avec succès !</span>";
} else {
// La colonne est déjà là
echo "<span style='color: #f59e0b;'>️ La colonne 'sort_order' existe déjà (Ignoré).</span>";
}
} else {
echo "<span style='color: #ef4444;'>⚠️ La table 'pf_budget_categories' n'existe pas ici.</span>";
}
echo "</div>";
}
echo "<h3>🎉 Migration terminée avec succès !</h3>";
echo "<p style='color: #64748b;'><em>Tu peux maintenant supprimer ce fichier par mesure de sécurité.</em></p>";
} catch (PDOException $e) {
echo "<h3 style='color: #ef4444;'>❌ Erreur fatale MySQL :</h3>";
echo "<pre style='background: #fee2e2; padding: 15px; border-radius: 8px;'>" . $e->getMessage() . "</pre>";
}
?>
+927 -75
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
<?php
header('Content-Type: application/json');
// On charge l'authentification et l'init PDO de l'espace familial courant automatiquement via tes includes globaux
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$title = trim($_POST['title'] ?? '');
$amount = floatval($_POST['amount'] ?? 0);
$expected_date = $_POST['expected_date'] ?? '';
if (empty($title) || $amount <= 0 || empty($expected_date)) {
echo json_encode(['success' => false, 'message' => tr('error_generic')]);
exit;
}
try {
// Grâce au multi-tenant par BDD, $pdo pointe déjà sur la base de la famille connectée
$stmt = $pdo->prepare("INSERT INTO pf_expected_expenses (title, amount, expected_date) VALUES (?, ?, ?)");
$stmt->execute([$title, $amount, $expected_date]);
echo json_encode(['success' => true, 'message' => tr('success_add_provision')]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]);
}
@@ -0,0 +1,19 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
$id = intval($_POST['id'] ?? 0);
if (!$id) {
echo json_encode(['success' => false, 'message' => 'Invalid ID']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM pf_expected_expenses WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
@@ -0,0 +1,35 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../../../../includes/auth.php';
require_once __DIR__ . '/../../../../includes/db.php';
require_once __DIR__ . '/../../../../includes/i18n.php';
try {
// Récupérer les provisions non payées, classées par date
$stmt = $pdo->query("SELECT id, title, amount, expected_date FROM pf_expected_expenses WHERE is_paid = 0 ORDER BY expected_date ASC");
$provisions = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($provisions)) {
$html = '<p class="pf-text-muted">' . tr('no_provisions') . '</p>';
} else {
$html = '<table class="pf-table" style="width:100%; border-collapse: collapse;">';
$html .= '<thead style="border-bottom: 2px solid var(--border-color); text-align: left;">';
$html .= '<tr><th>' . tr('provision_label') . '</th><th>' . tr('amount') . '</th><th>' . tr('expected_date') . '</th><th style="text-align:right;">Actions</th></tr>';
$html .= '</thead><tbody>';
foreach ($provisions as $p) {
$dateFormated = date('d/m/Y', strtotime($p['expected_date']));
$html .= '<tr style="border-bottom: 1px solid var(--border-color); height: 45px;">';
$html .= '<td>' . htmlspecialchars($p['title']) . '</td>';
$html .= '<td>' . number_format($p['amount'], 2, ',', ' ') . ' €</td>';
$html .= '<td>' . $dateFormated . '</td>';
$html .= '<td style="text-align:right;"><button class="pf-btn pf-btn-danger btn-delete-provision" data-id="' . $p['id'] . '">' . tr('btn_delete') . '</button></td>';
$html .= '</tr>';
}
$html .= '</tbody></table>';
}
echo json_encode(['success' => true, 'html' => $html]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+64 -48
View File
@@ -6,43 +6,54 @@ require_login();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
// --- ACTION : SAUVEGARDER (AJOUT OU MODIF) ---
// --- ACTION : SAUVEGARDER (AJOUT OU MODIF DU BUDGET) ---
if ($action === 'save') {
$id = $_POST['id'] ?? '';
$name = $_POST['name'];
$category = $_POST['category'];
$type = $_POST['type'];
$payment_day = empty($_POST['payment_day']) ? null : (int)$_POST['payment_day'];
$is_estimate = $_POST['is_estimate'];
$reg_month = $_POST['reg_month'];
$keywords = $_POST['mapping_keywords'] ?? '';
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
try {
// 1. Sécurisation des données entrantes
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$name = trim($_POST['name'] ?? '');
$category = $_POST['category'] ?? '';
$type = $_POST['type'] ?? 'Mensuel';
$payment_day = !empty($_POST['payment_day']) ? (int)$_POST['payment_day'] : null;
$is_estimate = isset($_POST['is_estimate']) ? (int)$_POST['is_estimate'] : 0;
$reg_month = !empty($_POST['reg_month']) ? trim($_POST['reg_month']) : null;
$keywords = trim($_POST['mapping_keywords'] ?? '');
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
// ==========================================
// NOUVELLE NORME COMPTABLE : Dépense = Négatif
// ==========================================
// 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;
}
// ==========================================
// 2. Gestion du montant (toujours stocker les dépenses en 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
if ($id) {
// UPDATE
$stmt = $pdo->prepare("UPDATE pf_budget_items SET name=?, amount=?, category=?, type=?, payment_day=?, is_estimate=?, reg_month=?, mapping_keywords=?, holiday_id=? WHERE id=?");
$stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id, $id]);
} else {
// INSERT
$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]);
// 3. Exécution de la requête
if ($id) {
// UPDATE
$stmt = $pdo->prepare("UPDATE pf_budget_items SET name=?, amount=?, category=?, type=?, payment_day=?, is_estimate=?, reg_month=?, mapping_keywords=?, holiday_id=? WHERE id=?");
$stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id, $id]);
} else {
// INSERT
$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) ---
@@ -59,7 +70,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
// --- ACTION : SUPPRIMER ---
// --- ACTION : SUPPRIMER UNE LIGNE DU BUDGET ---
if ($action === 'delete') {
$id = $_POST['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) ---
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 {
$id = !empty($_POST['expense_id']) ? (int)$_POST['expense_id'] : null;
@@ -92,26 +103,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$amount = floatval($_POST['amount'] ?? 0);
$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'] ?? '';
// Si la valeur est vide ou '0000-00-00'
// Si la valeur est vide ou invalide
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));
} else {
// Si la valeur vient d'un input "month" (ex: "2026-04"), on rajoute "-01"
$gestionMonth = (strlen($gestionMonthRaw) === 7) ? $gestionMonthRaw . '-01' : $gestionMonthRaw;
}
$label = trim($_POST['label'] ?? '');
$rawItemId = $_POST['budget_item_id'] ?? '';
$budgetItemId = null;
$salaryId = null;
$holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
if ($cat === 'School' && !empty($_POST['label_select'])) {
$label = trim($_POST['label_select']);
} elseif (($cat === 'Frais' || $cat === 'Income') && !empty($_POST['budget_item_id'])) {
$budgetItemId = (int)$_POST['budget_item_id'];
} elseif (!empty($rawItemId)) {
// 🔥 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é
@@ -122,16 +138,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$is_credit = isset($_POST['is_credit']) ? (int)$_POST['is_credit'] : 0;
$finalAmount = $is_credit ? abs($amount) : -abs($amount);
if ($id) {
// UPDATE
$pdo->prepare("UPDATE pf_expenses SET date_exp=?, gestion_month=?, category=?, label=?, amount=?, budget_item_id=?, holiday_id=? WHERE id=?")
->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $budgetItemId, $holidayId, $id]);
// UPDATE (Ajout de salary_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, $salaryId, $id]);
} else {
// INSERT
// INSERT (Ajout de salary_id)
$uniqueRef = "MANUAL_" . uniqid();
$pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $uniqueRef, $budgetItemId, $holidayId]);
$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, $salaryId]);
}
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()]);
}
+200 -77
View File
@@ -5,9 +5,30 @@ require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
require_login();
$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)
// =================================================================
@@ -42,7 +63,7 @@ if ($action === 'save_note') {
// 1. MISE A JOUR TABLEAU SALAIRES (AJAX)
if ($action === 'update_salary_config') {
header('Content-Type: application/json'); // On précise JSON ici
header('Content-Type: application/json');
$year = $_POST['year'];
$person = $_POST['person'];
$field = $_POST['field']; // salary, mensualite, etc.
@@ -50,7 +71,10 @@ if ($action === 'update_salary_config') {
// Liste des champs autorisés pour éviter les injections
$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->execute([$year, $person, $value]);
@@ -60,59 +84,85 @@ if ($action === 'update_salary_config') {
// 2. MISE A JOUR TABLEAU REPARTITION (AJAX)
if ($action === 'update_allocation') {
header('Content-Type: application/json');
$date = $_POST['month_date'];
$catId = $_POST['cat_id'];
$person = $_POST['person']; // 'amount_alex' ou 'amount_laia'
$value = floatval($_POST['value']);
header('Content-Type: application/json');
$date = $_POST['month_date'];
$catId = (int)$_POST['cat_id'];
$personId = (int)$_POST['person_id'];
$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)");
$stmt->execute([$date, $catId, $value]);
if ($catId <= 0 || $personId <= 0) {
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]);
exit;
}
// 3. GESTION DES CATEGORIES (Ajout)
if ($action === 'add_category') {
$name = trim($_POST['name']);
$target = trim($_POST['target']);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
if (!empty($name)) {
$stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, holiday_id) VALUES (?, ?, ?)");
$stmt->execute([$name, $target, $holiday_id]);
}
// --- NOUVEAU : Réponse AJAX ---
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => true]);
try {
$name = trim($_POST['name']);
$transfer_dest = trim($_POST['transfer_dest'] ?? '');
$target = floatval($_POST['target'] ?? 0);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
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]);
}
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
}
header("Location: " . $_SERVER['HTTP_REFERER']);
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
if ($action === 'update_category') {
$id = (int)$_POST['cat_id'];
$name = trim($_POST['name']);
$target = trim($_POST['target']);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
try {
$id = (int)$_POST['cat_id'];
$name = trim($_POST['name']);
$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)) {
$stmt = $pdo->prepare("UPDATE pf_alloc_categories SET name = ?, target = ?, holiday_id = ? WHERE id = ?");
$stmt->execute([$name, $target, $holiday_id, $id]);
}
if ($id > 0 && !empty($name)) {
$stmt = $pdo->prepare("UPDATE pf_alloc_categories SET name = ?, target = ?, transfer_dest = ?, holiday_id = ? WHERE id = ?");
$stmt->execute([$name, $target, $transfer_dest, $holiday_id, $id]);
}
// --- NOUVEAU : Réponse AJAX ---
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => true]);
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
}
header("Location: " . $_SERVER['HTTP_REFERER']);
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
@@ -136,43 +186,48 @@ if ($action === 'delete_category') {
// 6. VALIDATION DES VIREMENTS (Complex Business Logic)
if ($action === 'validate_transfers') {
header('Content-Type: application/json');
$person = $_POST['person'];
$personId = (int)$_POST['person_id']; // Reçoit l'ID parent
$monthDate = $_POST['month_date'];
try {
$pdo->beginTransaction();
$stmtP = $pdo->prepare("SELECT name FROM pf_people WHERE id = ?");
$stmtP->execute([$personId]);
$dbPersonName = $stmtP->fetchColumn();
if (!$dbPersonName) throw new Exception("Parent introuvable.");
$stmt = $pdo->prepare("
SELECT v.*, c.name as cat_name, c.target, c.holiday_id
FROM pf_alloc_values v
SELECT v.amount, c.name as cat_name, c.transfer_dest, c.holiday_id
FROM pf_alloc_values v
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);
$transfersToDo = [];
foreach ($budgetLines as $line) {
$amount = ($person === 'Alex') ? $line['amount_alex'] : $line['amount_laia'];
if ($amount <= 0) continue;
$amount = (float)$line['amount'];
if ($amount <= 0) continue;
$target = trim($line['target']);
$dest = trim($line['transfer_dest']);
$catName = trim($line['cat_name']);
$holidayId = $line['holiday_id'];
$holidayId = $line['holiday_id'];
$targetOwner = null;
if ($target === 'vers L.Perso') { $targetOwner = $person; }
elseif ($target === 'vers L.Pol') { $targetOwner = 'Pol'; }
elseif ($target === 'vers L.Pep') { $targetOwner = 'Pep'; }
elseif ($target === 'vers commune') { continue; }
if ($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 (!isset($transfersToDo[$targetOwner])) {
$transfersToDo[$targetOwner] = ['total_add' => 0, 'cats' => []];
}
$transfersToDo[$targetOwner]['total_add'] += $amount;
if (!isset($transfersToDo[$targetOwner]['cats'][$catName])) {
$transfersToDo[$targetOwner]['cats'][$catName] = ['amount' => 0, 'holiday_id' => $holidayId];
}
@@ -181,7 +236,6 @@ if ($action === 'validate_transfers') {
}
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->execute([$owner, $monthDate]);
$exists = $stmtCheck->fetchColumn() > 0;
@@ -200,54 +254,38 @@ if ($action === 'validate_transfers') {
}
}
// B. UPDATE TOTAL (Inchangé)
$stmtUpdTotal = $pdo->prepare("UPDATE pf_savings SET amount = amount + ? WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'");
$stmtUpdTotal->execute([$data['total_add'], $owner, $monthDate]);
// C. UPDATE CATÉGORIES (Modifié pour gérer le holiday_id)
foreach ($data['cats'] as $catName => $catInfo) {
$catAmount = $catInfo['amount'];
$catHolidayId = $catInfo['holiday_id']; // NOUVEAU
$catHolidayId = $catInfo['holiday_id'];
if ($catName === 'Eco Alex' || $catName === 'Eco Laia') { continue; }
if (strpos($catName, 'Eco P') === 0) { continue; }
$stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
$stmtCheckCat->execute([$owner, $monthDate, $catName]);
$catId = $stmtCheckCat->fetchColumn();
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->execute([$catAmount, $catHolidayId, $catId]);
} else {
// Insert
$stmtInsertCat = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)");
$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->execute();
$sysCatId = $stmtSys->fetchColumn();
if ($sysCatId) {
if ($person === 'Alex') {
$sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia)
VALUES (?, ?, 1, 0)
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]);
$stmtVal = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, person_id, amount)
VALUES (?, ?, ?, 1)
ON DUPLICATE KEY UPDATE amount = 1");
$stmtVal->execute([$monthDate, $sysCatId, $personId]);
}
$pdo->commit();
@@ -258,6 +296,91 @@ if ($action === 'validate_transfers') {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
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;
}
+53 -29
View File
@@ -3,15 +3,23 @@ require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
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)
// =================================================================
if ($action === 'update_single_entry') {
header('Content-Type: application/json');
$month = $_POST['month_date'];
$cat = $_POST['category'];
$owner = $_POST['owner'];
$amount = (float)$_POST['amount'];
$month = $_POST['month_date'] ?? '';
$cat = $_POST['category'] ?? '';
$owner = $_POST['owner'] ?? '';
$amount = (float)($_POST['amount'] ?? 0);
try {
if ($amount == 0 && $cat !== 'TOTAL_BANQUE') {
@@ -31,10 +39,10 @@ if ($action === 'update_single_entry') {
}
// --- ACTION : SUPPRESSION D'UNE ENTRÉE UNIQUE ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_entry') {
$owner = $_POST['owner'];
$date = $_POST['month_date'];
$cat = $_POST['category'];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'delete_entry') {
$owner = $_POST['owner'] ?? '';
$date = $_POST['month_date'] ?? '';
$cat = $_POST['category'] ?? '';
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
$stmt->execute([$owner, $date, $cat]);
@@ -44,11 +52,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
}
// --- ACTION : DUPLICATION DE MOIS ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'duplicate_month') {
$owner = $_POST['owner'];
$sourceDate = $_POST['source_date'];
$targetDate = $_POST['target_date'];
$newTotal = floatval($_POST['new_total']);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'duplicate_month') {
$owner = $_POST['owner'] ?? '';
$sourceDate = $_POST['source_date'] ?? '';
$targetDate = $_POST['target_date'] ?? '';
$newTotal = floatval($_POST['new_total'] ?? 0);
try {
$pdo->beginTransaction();
@@ -78,16 +86,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'dupli
echo json_encode(['success' => true]);
} catch (Exception $e) {
$pdo->rollBack();
if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
// --- ACTION : SUPPRESSION GLOBALE D'UN MOIS ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_month_global') {
$owner = $_POST['owner'];
$date = $_POST['month_date'];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'delete_month_global') {
$owner = $_POST['owner'] ?? '';
$date = $_POST['month_date'] ?? '';
try {
$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) ---
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$owner = $_POST['owner'];
// CORRECTION ICI : On récupère l'onglet de redirection (Nens, Alex ou Laia)
// 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.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($action, ['save_modal', ''])) {
$owner = $_POST['owner'] ?? '';
$redirectTab = $_POST['redirect_tab'] ?? $owner;
$dateInput = $_POST['month_date'];
$dateObj = new DateTime($dateInput);
$monthDate = $dateObj->format('Y-m-01');
$dateInput = $_POST['month_date'] ?? '';
$values = $_POST['values'] ?? []; // Tableau généré par nos champs JS: values[Catégorie]
try {
$dateObj = new DateTime($dateInput);
$monthDate = $dateObj->format('Y-m-01');
$pdo->beginTransaction();
// On supprime d'abord les anciennes données du mois pour cet utilisateur
@@ -130,11 +138,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
$pdo->commit();
// Redirection vers le bon onglet
header("Location: /budget.php?tab=epargne&owner=" . urlencode($redirectTab));
// On répond proprement selon le contexte (AJAX ou Fallback classique)
if ($isAjax) {
echo json_encode(['success' => true]);
} else {
header("Location: /budget.php?tab=epargne&owner=" . urlencode($redirectTab));
}
exit;
} catch (Exception $e) {
$pdo->rollBack();
die("Erreur : " . $e->getMessage());
if ($pdo->inTransaction()) $pdo->rollBack();
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;
}
+190
View File
@@ -0,0 +1,190 @@
<?php
require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
require_login();
header('Content-Type: application/json');
$action = $_GET['action'] ?? $_POST['action'] ?? '';
try {
if ($action === 'get_all') {
// 1. Comptes bancaires avec le nom du propriétaire lié (pf_people)
$accounts = $pdo->query("
SELECT a.*, p.name as owner_name
FROM pf_bank_accounts a
LEFT JOIN pf_people p ON a.owner_person_id = p.id
ORDER BY a.is_default DESC, a.name ASC
")->fetchAll(PDO::FETCH_ASSOC);
// 2. Catégories
$categories = $pdo->query("SELECT * FROM pf_budget_categories ORDER BY type ASC, label ASC")->fetchAll(PDO::FETCH_ASSOC);
// 3. Règles d'import
$rules = $pdo->query("
SELECT r.*, c.label as cat_label
FROM pf_import_rules r
LEFT JOIN pf_budget_categories c ON r.category COLLATE utf8mb4_unicode_ci = c.code COLLATE utf8mb4_unicode_ci
ORDER BY r.keyword ASC
")->fetchAll(PDO::FETCH_ASSOC);
// 4. Salaires (Année en cours)
$currentYear = (int)date('Y');
$salaries = $pdo->query("
SELECT p.name as person,
COALESCE(s.salary, 0) as salary,
COALESCE(s.mensualite, 0) as mensualite,
s.id as id
FROM pf_people p
LEFT JOIN pf_salary_config s ON p.name = s.person AND s.year = $currentYear
WHERE p.role NOT IN ('enfant', 'nounou') AND p.is_active = 1
ORDER BY p.name ASC
")->fetchAll(PDO::FETCH_ASSOC);
// 5. Paramètres Foyer (Devise + Mapping CSV)
$foyerData = $pdo->query("SELECT currency, csv_mapping FROM pf_foyer_settings LIMIT 1")->fetch(PDO::FETCH_ASSOC);
$currencySetting = $foyerData['currency'] ?? '€';
$csvMapping = !empty($foyerData['csv_mapping']) ? json_decode($foyerData['csv_mapping'], true) : null;
echo json_encode([
'success' => true,
'data' => [
'accounts' => $accounts,
'categories' => $categories,
'rules' => $rules,
'salaries' => $salaries,
'year' => $currentYear,
'currency' => $currencySetting,
'csv_mapping' => $csvMapping
]
]);
exit;
}
// --- GESTION DES COMPTES BANCAIRES ---
if ($action === 'add_account') {
$name = trim($_POST['name'] ?? '');
$type = $_POST['type'] ?? 'checking';
if (empty($name)) throw new Exception("Le nom du compte est obligatoire.");
$stmt = $pdo->prepare("INSERT INTO pf_bank_accounts (name, account_type, is_default) VALUES (?, ?, 0)");
$stmt->execute([$name, $type]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_account') {
$id = (int)($_POST['id'] ?? 0);
$count = $pdo->query("SELECT COUNT(*) FROM pf_bank_accounts")->fetchColumn();
if ($count <= 1) throw new Exception("Impossible de supprimer le dernier compte.");
$stmt = $pdo->prepare("DELETE FROM pf_bank_accounts WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
// --- GESTION DES CATÉGORIES ---
if ($action === 'add_category') {
$code = strtoupper(trim($_POST['code'] ?? ''));
$label = trim($_POST['label'] ?? '');
$type = $_POST['type'] ?? 'Expense';
$color = $_POST['color'] ?? '#cccccc';
$icon = trim($_POST['icon'] ?? '📌');
if (empty($code) || empty($label)) throw new Exception("Le code et le libellé sont obligatoires.");
$check = $pdo->prepare("SELECT COUNT(*) FROM pf_budget_categories WHERE code = ?");
$check->execute([$code]);
if ($check->fetchColumn() > 0) throw new Exception("Ce code de catégorie existe déjà.");
$stmt = $pdo->prepare("INSERT INTO pf_budget_categories (code, label, type, color, icon) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$code, $label, $type, $color, $icon]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_category') {
$id = (int)($_POST['id'] ?? 0);
$stmt = $pdo->prepare("DELETE FROM pf_budget_categories WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
// --- GESTION DES RÈGLES D'IMPORT ---
if ($action === 'add_rule') {
$keyword = strtoupper(trim($_POST['keyword'] ?? ''));
$category = trim($_POST['category'] ?? '');
if (empty($keyword) || empty($category)) throw new Exception("Le mot-clé et la catégorie sont obligatoires.");
$check = $pdo->prepare("SELECT COUNT(*) FROM pf_import_rules WHERE keyword = ?");
$check->execute([$keyword]);
if ($check->fetchColumn() > 0) throw new Exception("Une règle pour ce mot-clé existe déjà.");
$stmt = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category) VALUES (?, ?)");
$stmt->execute([$keyword, $category]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_rule') {
$id = (int)($_POST['id'] ?? 0);
$stmt = $pdo->prepare("DELETE FROM pf_import_rules WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
// --- GESTION DES SALAIRES ---
if ($action === 'save_salary') {
$id = (int)($_POST['id'] ?? 0);
$salary = (float)($_POST['salary'] ?? 0);
$mensualite = (float)($_POST['mensualite'] ?? 0);
if ($id <= 0) throw new Exception("ID de configuration de salaire invalide.");
$stmt = $pdo->prepare("UPDATE pf_salary_config SET salary = ?, mensualite = ? WHERE id = ?");
$stmt->execute([$salary, $mensualite, $id]);
echo json_encode(['success' => true]);
exit;
}
// --- GESTION DE LA DEVISE GLOBALE ---
if ($action === 'save_currency') {
$currency = trim($_POST['currency'] ?? '€');
$stmt = $pdo->prepare("UPDATE pf_foyer_settings SET currency = ?");
$stmt->execute([$currency]);
echo json_encode(['success' => true]);
exit;
}
// --- GESTION DU FORMAT CSV ---
if ($action === 'save_csv_mapping') {
$mapping = [
'delimiter' => $_POST['csv_delimiter'] ?? ';',
'date_format' => $_POST['csv_date_format'] ?? 'd/m/Y',
'col_date' => (int)($_POST['csv_col_date'] ?? 0),
'col_label' => (int)($_POST['csv_col_label'] ?? 1),
'amount_type' => $_POST['csv_amount_type'] ?? 'single',
'col_debit' => (int)($_POST['csv_col_debit'] ?? 8),
'col_credit' => (int)($_POST['csv_col_credit'] ?? 9),
'col_ref' => (int)($_POST['csv_col_ref'] ?? 3)
];
$jsonContent = json_encode($mapping);
$stmt = $pdo->prepare("UPDATE pf_foyer_settings SET csv_mapping = ?");
$stmt->execute([$jsonContent]);
echo json_encode(['success' => true]);
exit;
}
// SAUVEGARDE DU NOUVEL ORDRE DES CATÉGORIES
if (isset($_POST['action']) && $_POST['action'] === 'save_categories_order') {
$ids = json_decode($_POST['order'], true);
if (is_array($ids)) {
$stmt = $pdo->prepare("UPDATE pf_budget_categories SET sort_order = ? WHERE id = ?");
foreach ($ids as $index => $id) {
$stmt->execute([$index, (int)$id]);
}
}
echo json_encode(['success' => true]);
exit;
}
throw new Exception("Action API inconnue.");
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
?>
File diff suppressed because it is too large Load Diff
+122 -67
View File
@@ -1,9 +1,31 @@
<?php
// modules/budget/views/epargne.php
$requestedOwner = $_GET['owner'] ?? 'Nens';
$ownersToDisplay = ($requestedOwner === 'Nens') ? ['Pol', 'Pep'] : [$requestedOwner];
// --- 🧠 CONFIGURATION AGNOSTIQUE DES MEMBRES (MULTI-TENANT) ---
$stmtPeople = $pdo->query("SELECT name, role FROM pf_people ORDER BY id ASC");
$familyParents = [];
$familyKids = [];
while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) {
$role = strtolower(trim($row['role'] ?? ''));
if ($role === 'parent') {
$familyParents[] = $row['name'];
} elseif ($role === 'nounou' || $role === 'helper') {
// On ignore la nounou dans l'épargne
continue;
} else {
$familyKids[] = $row['name'];
}
}
// Sécurité anti-page blanche si la BDD est mal configurée
if (empty($familyParents)) $familyParents = ['Parent 1', 'Parent 2'];
$requestedOwner = $_GET['owner'] ?? (!empty($familyKids) ? 'KIDS' : ($familyParents[0] ?? 'KIDS'));
$ownersToDisplay = ($requestedOwner === 'KIDS') ? $familyKids : [$requestedOwner];
// --- RÉCUPÉRATION CONFIGURATION DES MOIS ---
$cycleConfigs = [];
$stmtNotes = $pdo->query("SELECT reference_id, content FROM pf_notes WHERE note_type = 'month_config'");
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) {
$m = date('m', strtotime($dateString));
$y = date('Y', strtotime($dateString));
@@ -25,13 +46,22 @@ function getMonthName($dateString) {
<div class="budget-view">
<div class="view-header">
<div class="owner-tabs">
<a href="?tab=epargne&owner=Alex" class="owner-tab <?= $requestedOwner === 'Alex' ? 'active' : '' ?>">Alex</a>
<a href="?tab=epargne&owner=Laia" class="owner-tab <?= $requestedOwner === 'Laia' ? 'active' : '' ?>">Laia</a>
<a href="?tab=epargne&owner=Nens" class="owner-tab <?= $requestedOwner === 'Nens' ? 'active' : '' ?>">Nens 👶</a>
<?php if (!empty($familyKids)): ?>
<a href="?tab=epargne&owner=KIDS" class="owner-tab <?= $requestedOwner === 'KIDS' ? 'active' : '' ?>">
<?= tr('budget_tab_kids') ?? 'Enfants 👶' ?>
</a>
<?php endif; ?>
<?php foreach ($familyParents as $p): ?>
<a href="?tab=epargne&owner=<?= urlencode($p) ?>" class="owner-tab <?= $requestedOwner === $p ? 'active' : '' ?>">
<?= htmlspecialchars($p) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<?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->execute([$currentOwner]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
@@ -51,31 +81,29 @@ function getMonthName($dateString) {
$months = array_slice($months, 0, 7);
sort($allCategories);
// Définition de la classe couleur selon le propriétaire
$ownerTextClass = '';
if ($currentOwner === 'Alex') $ownerTextClass = 'txt-alex';
elseif ($currentOwner === 'Laia') $ownerTextClass = 'txt-laia';
else $ownerTextClass = 'txt-global'; // Pour Pol et Pep
$ownerTextClass = 'txt-global';
// 🔄 CORRECTION ICI : Création d'un nom "safe" sans espace pour les classes CSS
$safeOwnerCls = htmlspecialchars(str_replace(' ', '_', $currentOwner), ENT_QUOTES);
?>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; margin-top: <?= ($requestedOwner === 'Nens' && $currentOwner !== 'Pol') ? '40px' : '0' ?>;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; margin-top: <?= ($requestedOwner === 'KIDS' && $index > 0) ? '40px' : '0' ?>;">
<div style="flex-grow: 1;">
<?php if ($requestedOwner === 'Nens'):
$themeClass = 'theme-' . strtolower($currentOwner);
<?php if ($requestedOwner === 'KIDS'):
$themeClass = 'theme-' . strtolower($safeOwnerCls);
?>
<h3 class="nens-title <?= $themeClass ?>" style="margin:0; font-size:1.2rem;">
<?= $currentOwner ?>
<?= htmlspecialchars($currentOwner) ?>
</h3>
<?php endif; ?>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<?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') ?>
</button>
<?php endif; ?>
<button onclick="openCustomSavingsModal('<?= $currentOwner ?>')" class="pf-btn">
<button onclick="openCustomSavingsModal('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn">
<?= tr('bud_sav_add_month') ?>
</button>
</div>
@@ -87,7 +115,7 @@ function getMonthName($dateString) {
<p><?= sprintf(tr('bud_sav_no_data'), htmlspecialchars($currentOwner)) ?></p>
</div>
<?php else: ?>
<table class="pf-table savings-table nens-table theme-<?= strtolower($currentOwner) ?>" style="margin-top:0; box-shadow:none; border-radius:16px;">
<table class="pf-table savings-table nens-table theme-<?= strtolower($safeOwnerCls) ?>" style="margin-top:0; box-shadow:none; border-radius:16px;">
<thead>
<tr>
<th class="sticky-col" style="background:#f8fafc;"><?= tr('bud_sav_post_month') ?></th>
@@ -106,11 +134,11 @@ function getMonthName($dateString) {
<div class="month-actions" style="justify-content: center; width: 100%;">
<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")))'>
onclick='editCustomSavingsMonth("<?= $month ?>", "<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>", JSON.parse(this.getAttribute("data-json")))'>
✏️
</button>
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_delete_month') ?>"
onclick="deleteEntireMonth('<?= $month ?>', '<?= $currentOwner ?>')"
onclick="deleteEntireMonth('<?= $month ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')"
style="color: #ef4444; border-color: #fca5a5; background: #fef2f2;">
🗑️
</button>
@@ -129,11 +157,11 @@ function getMonthName($dateString) {
<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 ?>"
class="prev-input total-input-<?= $safeOwnerCls ?>-<?= $month ?>"
style="width: 70px; font-weight:bold; color:#2563eb;"
value="<?= $val != 0 ? round($val) : '' ?>"
placeholder="0"
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= $currentOwner ?>', this)">
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
<span style="color:#2563eb; font-weight:bold; font-size:0.9rem;">€</span>
</div>
</td>
@@ -149,11 +177,11 @@ function getMonthName($dateString) {
<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 ?>"
class="prev-input <?= $ownerTextClass ?> cat-input-<?= $safeOwnerCls ?>-<?= $month ?>"
style="width: 70px;"
value="<?= $amount != 0 ? round($amount) : '' ?>"
placeholder="-"
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= $currentOwner ?>', this)">
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
</div>
</td>
<?php endforeach; ?>
@@ -168,7 +196,7 @@ function getMonthName($dateString) {
foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0);
$extra = $total - $sum;
?>
<td class="text-center font-bold sum-target" id="extra_<?= $currentOwner ?>_<?= $month ?>" style="color: <?= $extra >= 0 ? '#10b981' : '#ef4444' ?>; padding:12px;">
<td class="text-center font-bold sum-target" id="extra_<?= $safeOwnerCls ?>_<?= $month ?>" style="color: <?= $extra >= 0 ? '#10b981' : '#ef4444' ?>; padding:12px;">
<?= number_format($extra, 0, ',', ' ') ?> €
</td>
<?php endforeach; ?>
@@ -260,10 +288,13 @@ window.I18N = {
'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?>
};
// --- 2. GESTION DE L'ÉDITION INVISIBLE EN DIRECT ---
// Sécurisation de la devise pour la calculatrice
const systemCurrency = (typeof window.CONFIG !== 'undefined' && window.CONFIG.CURRENCY) ? window.CONFIG.CURRENCY : '€';
// --- 2. GESTION DE L'ÉDITION INVISIBLE EN DIRECT (CORRIGÉE 🚀) ---
const cycleConfigs = <?= json_encode($cycleConfigs ?? []) ?>;
function updateEpargneCell(month, category, owner, inputEl) {
async function updateEpargneCell(month, category, owner, inputEl) {
const val = parseFloat(inputEl.value) || 0;
const formData = new FormData();
formData.append('action', 'update_single_entry');
@@ -271,22 +302,38 @@ function updateEpargneCell(month, category, owner, inputEl) {
formData.append('category', category);
formData.append('owner', owner);
formData.append('amount', val);
formData.append('ajax', '1'); // 🚀 CRITIQUE : Informe l'API
fetch('modules/budget/includes/api/save-savings.php', {
method: 'POST',
body: formData
}).catch(err => alert(window.I18N['bud_err_tech'] || 'Erreur technique'));
try {
// 🚀 CRITIQUE : pachaFetch + Chemin absolu
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', {
method: 'POST',
body: formData
});
const totalInput = document.querySelector(`.total-input-${owner}-${month}`);
if (!result.success) {
console.error("Erreur de sauvegarde :", result.error);
alert(window.I18N['bud_err_tech'] + " : " + (result.error || "Erreur serveur"));
return; // On stoppe la mise à jour visuelle si la BDD a refusé
}
} catch (err) {
console.error("Erreur réseau :", err);
alert(window.I18N['bud_err_tech'] || 'Erreur technique');
return;
}
// Si on arrive ici, la BDD est bien à jour ! On met à jour le visuel.
const safeOwnerClass = owner.replace(/\s+/g, '_');
const totalInput = document.querySelector(`.total-input-${CSS.escape(safeOwnerClass)}-${month}`);
const totalVal = parseFloat(totalInput ? totalInput.value : 0) || 0;
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;
});
const extra = totalVal - sumCats;
const extraCell = document.getElementById(`extra_${owner}_${month}`);
const extraCell = document.getElementById(`extra_${safeOwnerClass}_${month}`);
if (extraCell) {
extraCell.innerText = Math.round(extra).toLocaleString(window.appLang) + ' €';
@@ -390,7 +437,7 @@ window.onclick = function(event) {
const savingsForm = document.getElementById('savingsForm');
if (savingsForm) {
savingsForm.addEventListener('submit', function(e) {
savingsForm.addEventListener('submit', async function(e) {
e.preventDefault();
const ym = document.getElementById('sav_month').value;
@@ -404,23 +451,32 @@ if (savingsForm) {
submitBtn.disabled = true;
const formData = new FormData(this);
formData.append('ajax', '1'); // 🚀 CRITIQUE
const actionUrl = this.getAttribute('action');
const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl;
fetch(finalUrl, { method: 'POST', body: formData })
.then(response => response.text())
.then(text => { window.location.reload(); })
.catch(error => {
console.error("Erreur:", error);
try {
// 🚀 CRITIQUE : pachaFetch + Chemin absolu forcé
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', {
method: 'POST',
body: formData
});
if (result.success) {
window.location.reload(); // Ne recharge QUE si le serveur a dit OK
} else {
alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (result.error || "Inconnue"));
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
} catch (error) {
console.error("Erreur catch:", error);
alert(window.I18N['bud_err_tech'] || 'Erreur Technique');
submitBtn.innerText = originalText;
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 msg = rawMsg.replace('%m', monthDate).replace('%o', owner);
if (!confirm(msg)) return;
@@ -429,13 +485,18 @@ function deleteEntireMonth(monthDate, owner) {
formData.append("action", "delete_month_global");
formData.append("month_date", monthDate);
formData.append("owner", owner);
formData.append("ajax", "1"); // 🚀 CRITIQUE
fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
.then(() => window.location.reload())
.catch(err => alert(window.I18N['bud_err_delete'] || 'Erreur lors de la suppression'));
try {
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: "POST", body: formData });
if (result.success) window.location.reload();
else alert(result.error || window.I18N['bud_err_delete']);
} catch(err) {
alert(window.I18N['bud_err_delete'] || 'Erreur lors de la suppression');
}
}
function duplicateLastMonth(lastMonthDate, owner) {
async function duplicateLastMonth(lastMonthDate, owner) {
let dateObj = new Date(lastMonthDate);
dateObj.setMonth(dateObj.getMonth() + 1);
let year = dateObj.getFullYear();
@@ -447,16 +508,13 @@ function duplicateLastMonth(lastMonthDate, owner) {
return str.charAt(0).toUpperCase() + str.slice(1);
};
const sourceName = formatMonth(lastMonthDate);
const targetName = formatMonth(nextMonthStr);
let defaultTotal = "";
if (cycleConfigs[nextMonthStr] && cycleConfigs[nextMonthStr].start_balance !== undefined) {
defaultTotal = cycleConfigs[nextMonthStr].start_balance;
}
const rawMsg = window.I18N['bud_sav_prompt_duplicate'] || "Dupliquer %s vers %t1 ?";
const message = rawMsg.replace('%s', sourceName).replace('%t1', targetName).replace('%t2', targetName);
const rawMsg = window.I18N['bud_sav_prompt_duplicate'] || "Dupliquer vers le mois suivant ?";
const message = rawMsg.replace('%s', formatMonth(lastMonthDate)).replace(/%t[12]/g, formatMonth(nextMonthStr));
let newTotal = prompt(message, defaultTotal);
@@ -467,22 +525,19 @@ function duplicateLastMonth(lastMonthDate, owner) {
formData.append("target_date", nextMonthStr);
formData.append("new_total", newTotal);
formData.append("owner", owner);
formData.append("ajax", "1"); // 🚀 CRITIQUE
fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
.then(async r => {
const text = await r.text();
try {
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) {
try {
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: "POST", body: formData });
if (result.success) {
window.location.reload();
} else {
alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (result.error || "Inconnue"));
}
})
.catch(err => {
} catch(err) {
console.error(err);
alert(window.I18N['bud_err_network_dup'] || 'Erreur réseau.');
});
}
}
}
@@ -528,7 +583,7 @@ function updateSumResult() {
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) {
+265
View File
@@ -0,0 +1,265 @@
<?php
// modules/budget/views/provisions.php
// --- RÉCUPÉRATION DES DÉPENSES PRÉVUES (MULTI-TENANT PDO) ---
$stmt = $pdo->query("SELECT id, title, amount, expected_date FROM pf_expected_expenses WHERE is_paid = 0 ORDER BY expected_date ASC");
$provisions = $stmt->fetchAll(PDO::FETCH_ASSOC);
// --- RÉCUPÉRATION DES APPORTS ET DETTES (CLEARING) ---
$currentYear = date('Y');
// 1. Apports de base (Eco Family) des parents configurés pour l'année
$stmtConfig = $pdo->prepare("SELECT person, eco_family FROM pf_salary_config WHERE year = ?");
$stmtConfig->execute([$currentYear]);
$configs = $stmtConfig->fetchAll(PDO::FETCH_KEY_PAIR);
// 2. Dettes non résolues par personne (issues des Avances & Tricount)
$stmtDebts = $pdo->query("SELECT payer, SUM(amount) as total_debt FROM pf_advances WHERE is_resolved = 0 GROUP BY payer");
$debts = $stmtDebts->fetchAll(PDO::FETCH_KEY_PAIR);
function formatDate($dateString) {
if (!$dateString) return '';
return date('d/m/Y', strtotime($dateString));
}
?>
<div class="budget-view">
<div class="view-header">
<h2><?= tr('budget_provisions_title') ?></h2>
<button class="pf-btn pf-btn-primary" onclick="openOptimizeModal()">
✨ <?= tr('btn_optimize_cashflow') ?>
</button>
</div>
<div class="pf-card">
<h3 class="pf-card-h2"><?= tr('add_new_provision') ?></h3>
<form id="form-add-provision" data-action="/modules/budget/includes/api/add-provision.php">
<div class="provisions-form-grid">
<div class="pf-form-group">
<label class="pf-label" for="prov-title"><?= tr('provision_label') ?></label>
<input type="text" id="prov-title" name="title" class="pf-input" placeholder="<?= tr('provision_placeholder_wood') ?>" required>
</div>
<div class="pf-form-group">
<label class="pf-label" for="prov-amount"><?= tr('amount') ?> (€)</label>
<input type="number" id="prov-amount" name="amount" class="pf-input no-spinners input-amount-highlight" step="0.01" min="0.01" required>
</div>
<div class="pf-form-group">
<label class="pf-label" for="prov-date"><?= tr('expected_date') ?></label>
<input type="date" id="prov-date" name="expected_date" class="pf-input" required>
</div>
<button type="submit" class="pf-btn pf-btn-secondary">
<?= tr('btn_add') ?>
</button>
</div>
</form>
</div>
<div class="budget-table-card table-responsive">
<?php if (empty($provisions)): ?>
<div class="optimization-hint" style="padding: 30px;">
<p><?= tr('no_provisions') ?></p>
</div>
<?php else: ?>
<table class="pf-table savings-table provisions-table">
<thead>
<tr>
<th><?= tr('provision_label') ?></th>
<th style="width: 150px;"><?= tr('amount') ?></th>
<th style="width: 150px;"><?= tr('expected_date') ?></th>
<th style="width: 100px;"><?= tr('actions') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($provisions as $p): ?>
<tr>
<td class="prov-title-cell">
<?= htmlspecialchars($p['title']) ?>
</td>
<td class="prov-amount-cell">
<?= number_format($p['amount'], 2, ',', ' ') ?> €
</td>
<td class="prov-date-cell">
<?= formatDate($p['expected_date']) ?>
</td>
<td class="text-right" style="padding-right: 15px;">
<button class="btn-icon-action delete btn-safe-click" title="<?= tr('btn_delete') ?>" onclick="deleteProvision(<?= $p['id'] ?>)">
🗑️
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<div id="optimizeModal" class="pf-modal">
<div class="pf-modal-content provisions-modal-content">
<div class="pf-modal-header">
<h3 class="pf-modal-title">🧠 <?= tr('optimization_assistant_title') ?></h3>
<button type="button" class="pf-modal-close" onclick="closeOptimizeModal()">&times;</button>
</div>
<form id="form-optimize" data-action="/modules/budget/includes/api/optimize-cashflow.php">
<div id="dynamic-savings-inputs">
<?php if (empty($configs)): ?>
<p class="text-danger font-bold pf-muted-tiny"><?= tr('budget_opti_no_config') ?></p>
<input type="number" step="0.01" min="0" name="savings[Global]" class="pf-input no-spinners" required>
<?php else: ?>
<?php foreach ($configs as $person => $ecoBase):
$debt = $debts[$person] ?? 0;
?>
<div class="pf-form-group provision-person-card">
<label class="pf-label provision-person-header">
<strong class="text-main"><?= htmlspecialchars($person) ?></strong>
<span class="pf-muted-note">
<?php if ($debt > 0): ?>
<span class="text-danger font-bold">(Dette en attente : <?= (float)$debt ?> €)</span>
<?php endif; ?>
</span>
</label>
<div class="provision-input-group">
<input type="number" step="0.01" min="0" name="savings[<?= htmlspecialchars($person) ?>]" class="pf-input no-spinners input-amount-success" value="<?= (float)$ecoBase ?>" required>
<span class="currency-symbol">€</span>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<button type="submit" class="pf-btn pf-btn-primary btn-block">
<?= tr('calculate') ?>
</button>
</form>
<hr class="pf-divider">
<div id="optimization-results">
<p class="optimization-hint">
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
</p>
</div>
</div>
</div>
<script>
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
'btn_delete': <?= json_encode(tr('btn_delete')) ?>,
'error_generic': <?= json_encode(tr('error_generic')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>
};
// --- 2. GESTION DU FORMULAIRE D'AJOUT ---
const formAdd = document.getElementById('form-add-provision');
if (formAdd) {
formAdd.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerText;
submitBtn.innerText = '⏳';
submitBtn.disabled = true;
try {
const result = await pachaFetch(this.getAttribute('data-action'), {
method: 'POST',
body: new FormData(this)
});
if (result.success) {
window.location.reload();
} else {
alert(result.message || window.I18N['error_generic']);
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
} catch (error) {
alert(window.I18N['bud_err_tech']);
submitBtn.innerText = originalText;
submitBtn.disabled = false;
}
});
}
// --- 3. GESTION DE LA SUPPRESSION ---
async function deleteProvision(id) {
if (!confirm(window.I18N['btn_delete'] + ' ?')) return;
try {
const result = await pachaFetch('/modules/budget/includes/api/delete-provision.php', {
method: "POST",
body: new URLSearchParams({ id: id })
});
if (result.success) {
window.location.reload();
} else {
alert(result.message);
}
} catch(err) {
alert(window.I18N['bud_err_tech']);
}
}
// --- 4. CONTROL DE LA MODALE D'OPTIMISATION ---
function openOptimizeModal() {
document.getElementById('optimization-results').innerHTML = `
<p class="optimization-hint">
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
</p>`;
document.getElementById('optimizeModal').classList.add('open');
document.body.classList.add('no-scroll');
}
function closeOptimizeModal() {
document.getElementById('optimizeModal').classList.remove('open');
document.body.classList.remove('no-scroll');
}
window.onclick = function(event) {
const modal = document.getElementById('optimizeModal');
if (event.target == modal) {
closeOptimizeModal();
}
}
// --- 5. EXECUTION ET SUBMIT DE L'ALGORITHME (formOptimize) ---
const formOptimize = document.getElementById('form-optimize');
if (formOptimize) {
formOptimize.addEventListener('submit', async function(e) {
e.preventDefault();
const resultsContainer = document.getElementById('optimization-results');
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.disabled = true;
resultsContainer.innerHTML = '<div class="optimization-hint">⏳ Analyse des quinzaines bancaires...</div>';
try {
const result = await pachaFetch(this.getAttribute('data-action'), {
method: 'POST',
body: new FormData(this)
});
if (result.success) {
resultsContainer.innerHTML = result.html;
} else {
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${result.message}</p>`;
}
} catch (error) {
console.error("Erreur d'optimisation :", error);
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${window.I18N['bud_err_tech']}</p>`;
} finally {
submitBtn.disabled = false;
}
});
}
</script>
+231 -269
View File
@@ -1,46 +1,60 @@
<?php
// modules/budget/views/recap.php
// 1. Récupération des Items du Budget
$stmt = $pdo->query("SELECT * FROM pf_budget_items ORDER BY category DESC, sort_order ASC, name ASC");
$items = $stmt->fetchAll();
// 1. Récupération des Catégories dynamiques
$stmtCats = $pdo->query("SELECT code, label, icon, type FROM pf_budget_categories ORDER BY type DESC, label ASC");
$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");
$defaultActiveMonth = $stmtActive->fetchColumn();
if (!$defaultActiveMonth) {
$defaultActiveMonth = date('Y-m-01');
}
// 3. Assigner le mois et l'année en fonction du mois ouvert
$defaultActiveMonth = $stmtActive->fetchColumn() ?: date('Y-m-01');
$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";
$sqlReal = "SELECT budget_item_id, SUM(amount) as total_real
FROM pf_expenses
WHERE gestion_month = ? AND budget_item_id IS NOT NULL
GROUP BY budget_item_id";
$stmtReal = $pdo->prepare($sqlReal);
// 5. Récupération optimisée et unifiée du Réel
// On récupère tout en une seule requête pour éviter les décalages entre ID et Catégorie
$stmtReal = $pdo->prepare("
SELECT budget_item_id, category, SUM(amount) as total_real
FROM pf_expenses
WHERE gestion_month = ?
GROUP BY budget_item_id, category
");
$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
FROM pf_expenses
WHERE gestion_month = ? AND budget_item_id IS NULL
GROUP BY category";
$stmtCatReal = $pdo->prepare($sqlCatReal);
$stmtCatReal->execute([$viewMonthDate]);
$catTotals = $stmtCatReal->fetchAll(PDO::FETCH_KEY_PAIR);
// On initialise les tableaux de correspondance
$realTotalsById = []; // Pour les lignes liées par ID
$realTotalsByCat = []; // Pour les lignes orphelines (NULL) liées par Catégorie
$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]);
$unlinkedExpenses = $stmtLabels->fetchAll(PDO::FETCH_ASSOC);
$moisFr = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
$monthTranslationKey = 'month_' . str_pad((int)$currentMonth, 2, '0', STR_PAD_LEFT);
$currentMonthName = tr($monthTranslationKey) . ' ' . $currentYear;
// Gestion des libellés et totaux
$currentMonthName = tr('month_' . str_pad((int)$currentMonth, 2, '0', STR_PAD_LEFT)) . ' ' . $currentYear;
$totalDepenses = 0;
$totalRevenus = 0;
?>
@@ -64,124 +78,148 @@ $totalRevenus = 0;
</tr>
</thead>
<tbody>
<?php foreach ($items as $item):
// --- 1. CALCUL DES TOTAUX PRÉVUS ---
$targetAbs = abs((float)$item['amount']);
$amountToAdd = ($item['type'] === 'Annuel') ? $targetAbs / 12 : $targetAbs;
<?php foreach ($salaries as $salary):
$mensualite = (float)$salary['mensualite'];
$totalRevenus += $mensualite;
if ($item['category'] === 'income') $totalRevenus += $amountToAdd;
else $totalDepenses += $amountToAdd;
// --- 2. CALCUL DU RÉEL (Logique optimisée) ---
$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';
// Calcul du réel perçu
$realPerçu = isset($realTotalsBySalary[$salary['id']]) ? (float)$realTotalsBySalary[$salary['id']] : 0;
$gap = $realPerçu - $mensualite;
$isAutoChecked = ($realPerçu >= ($mensualite - 0.10));
?>
<tr class="<?= $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']);
$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;">
<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'])): ?>
<span title="<?= 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>
<span title="Reconnaissance bancaire : <?= htmlspecialchars($item['mapping_keywords']) ?>" style="font-size:0.7rem; cursor:help;">🔗</span>
<?php endif; ?>
</td>
<td class="cell-amount" style="font-weight:600; padding:15px; color:<?= $item['category']==='income'?'#10b981':'#1e293b' ?>;">
<?= number_format($targetAbs, 2, ',', ' ') ?> €
<td class="cell-amount" style="font-weight:600; padding:15px; color:#1e293b;">
- <?= number_format($targetAbs, 2, ',', ' ') ?> €
<?php if ($hasMatchingExpense): ?>
<?php
// Le "Gap" est la différence visuelle.
$gap = $realAbs - $targetAbs;
<?php $gap = $realAbs - $targetAbs;
if ($gap > 0.05): ?>
<div style="font-size:0.75rem; color:#ef4444; font-weight:bold;">
<?= $item['category'] === 'income' ? tr('bud_bonus') : tr('bud_overrun') ?> : +<?= number_format($gap, 2, ',', ' ') ?> €
</div>
<div style="font-size:0.75rem; color:#ef4444; font-weight:bold;">Dépassé : +<?= number_format($gap, 2, ',', ' ') ?> €</div>
<?php elseif ($gap < -0.05): ?>
<div style="font-size:0.75rem; color:#f59e0b; font-weight:normal;">
<?= tr('bud_remaining') ?> : <?= number_format(abs($gap), 2, ',', ' ') ?> €
</div>
<div style="font-size:0.75rem; color:#f59e0b;">Reste : <?= number_format(abs($gap), 2, ',', ' ') ?> €</div>
<?php else: ?>
<div style="font-size:0.75rem; color:#10b981; font-weight:normal;">
<?= tr('bud_exact_amount') ?> ✓
</div>
<div style="font-size:0.75rem; color:#10b981;">Atteint ✓</div>
<?php endif; ?>
<?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; ?>
</td>
<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;">
<?= tr('bud_freq_'.strtolower($item['type'])) ?>
<span class="badge-type" style="background:#e2e8f0; padding:4px 8px; border-radius:12px; font-size:0.8rem; font-weight:600; color:#475569;">
<?= htmlspecialchars($item['type']) ?>
</span>
</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;">
<?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;">
<span>✓</span> <?= tr('bud_state_validated') ?>
</div>
<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>
<?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;">
<span>⏳</span> <?= tr('bud_state_partial') ?>
</div>
<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> <?= tr('bud_state_waiting') ?>
</div>
<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;">
<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 delete" onclick="deleteRecapItem(<?= $item['id'] ?>)" title="<?= tr('delete') ?>">🗑️</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="Supprimer">🗑️</button>
</div>
</td>
</tr>
@@ -189,34 +227,30 @@ $totalRevenus = 0;
</tbody>
<tfoot style="background:#f8fafc;">
<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>
</tr>
<tr>
<td colspan="1" style="padding:15px;"><strong><?= tr('bud_total_expenses_smoothed') ?></strong></td>
<td colspan="1" style="padding:15px;"><strong>Totalpenses & Estimations</strong></td>
<td colspan="5" style="padding:15px; color:#ef4444;"><strong>- <?= number_format($totalDepenses, 2, ',', ' ') ?> €</strong></td>
</tr>
<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; ?>
<td colspan="5" style="padding:15px; font-size: 1.3em;" class="<?= $balance >= 0 ? 'text-success' : 'text-danger' ?>">
<strong style="color:<?= $balance >= 0 ? '#10b981' : '#ef4444' ?>;"><?= number_format($balance, 2, ',', ' ') ?> € / <?= tr('bud_month_short') ?></strong>
<td colspan="5" style="padding:15px; font-size: 1.3em;">
<strong style="color:<?= $balance >= 0 ? '#10b981' : '#ef4444' ?>;"><?= number_format($balance, 2, ',', ' ') ?> € / mois</strong>
</td>
</tr>
</tfoot>
</table>
</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 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 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>
</div>
@@ -225,90 +259,66 @@ $totalRevenus = 0;
<input type="hidden" name="id" id="item_id">
<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>
<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;">
</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>
<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="ex: Assurance Auto, Estimation Courses..." style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
</div>
<div style="display:flex; gap:15px; 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;"><?= 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;">
</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_day') ?></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;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Type de Dépense</label>
<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 style="display:flex; gap:15px; 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;"><?= tr('bud_category') ?></label>
<select name="category" id="item_category" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
<option value="expense"><?= tr('bud_cat_expense') ?></option>
<option value="income"><?= tr('bud_cat_income') ?></option>
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">
Catégorie(s) Cible(s) <small style="font-weight:normal;">(Maintenez Ctrl/Cmd pour choix multiple)</small>
</label>
<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>
</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_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;">
<option value="Mensuel"><?= tr('bud_freq_mensuel') ?></option>
<option value="Annuel"><?= tr('bud_freq_annuel') ?></option>
<option value="Mensuel">Mensuel</option>
<option value="Annuel">Annuel</option>
</select>
</div>
</div>
<div style="display:flex; gap:15px; margin-bottom:25px;">
<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_amount_type') ?></label>
<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"><?= 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 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;">Mots-clés (Reconnaissance Bancaire)</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;">
<small style="color:#64748b; font-size:0.75rem;">Optionnel. Permet au système de lier automatiquement un import CSV à cette ligne.</small>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px;">
<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="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>
<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;">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;">Enregistrer</button>
</div>
</form>
</div>
</div>
<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) {
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("recapForm").reset();
}
@@ -322,113 +332,65 @@ function closeRecapModal() {
}
window.onclick = function(event) {
const modal = document.getElementById('budgetRecapModal');
if (event.target == modal) {
closeRecapModal();
}
if (event.target == document.getElementById('budgetRecapModal')) closeRecapModal();
}
function editRecapItem(item) {
const data = typeof item === "string" ? JSON.parse(item) : item;
document.getElementById("recapModalTitle").innerText = (window.I18N['bud_recap_modal_edit'] || 'Editer') + " : " + data.name;
document.getElementById("recapModalTitle").innerText = "Editer : " + data.name;
document.getElementById("item_id").value = data.id;
document.getElementById("item_name").value = data.name;
document.getElementById("item_keywords").value = data.mapping_keywords || '';
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_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("budgetRecapModal").style.display = "flex";
document.body.classList.add('no-scroll');
}
// --- 2. INTERCEPTION ASYNCHRONE DU FORMULAIRE ---
const recapForm = document.getElementById('recapForm');
if (recapForm) {
recapForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerText;
submitBtn.innerText = window.I18N['bud_saving'] || '⏳ ...';
submitBtn.disabled = true;
document.getElementById('recapForm').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = this.querySelector('button[type="submit"]');
btn.disabled = true; btn.innerText = '⏳ ...';
const formData = new FormData(this);
formData.append('ajax', '1');
// 💡 Utilisation sécurisée de getAttribute
const actionUrl = this.getAttribute('action');
const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl;
try {
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
const formData = new FormData(this);
const cats = formData.getAll('category_multi[]');
formData.delete('category_multi[]');
formData.append('category', cats.join(','));
formData.append('ajax', '1');
const url = this.getAttribute('action');
try {
const response = await fetch("modules/budget/includes/api/manage-item.php", {
method: "POST",
body: formData,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const textResult = await response.text();
try {
const result = JSON.parse(textResult);
// On s'assure que si l'API ne renvoie pas success, on le signale
if (result.success !== false) {
window.location.reload();
} else {
alert((window.I18N['bud_err_delete'] || 'Erreur de suppression') + " : " + (result.error || ""));
}
} catch (jsonErr) {
console.error("Réponse non-JSON lors de la suppression :", textResult);
window.location.reload(); // Fallback si l'API redirige au lieu de répondre en JSON
}
const res = await fetch(url.startsWith('/') ? url.substring(1) : url, { method: 'POST', body: formData });
const data = await res.json();
if (data.success) window.location.reload();
else alert("Erreur : " + data.error);
} catch (err) {
console.error("Erreur réseau Suppression:", err);
alert(window.I18N['bud_err_delete'] || 'Erreur réseau');
alert("Erreur technique lors de la sauvegarde.");
} 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>
+523
View File
@@ -0,0 +1,523 @@
<?php
// Sécurité : Ce fichier ne doit pas être appelé directement
if (!defined('CURRENCY')) {
define('CURRENCY', '€');
}
?>
<div class="budget-settings-backdrop" id="modal-budget-settings">
<div class="budget-settings-modal">
<div class="bs-header">
<h3>⚙️ <?= tr('budget_settings_title') ?></h3>
<button class="bs-close" onclick="closeBudgetSettings()">×</button>
</div>
<div class="bs-layout">
<div class="bs-sidebar">
<button class="bs-tab-btn active" onclick="switchBsTab('accounts', this)">
<?= tr('bs_tab_accounts') ?>
<?php if (!$hasAccounts): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
</button>
<button class="bs-tab-btn" onclick="switchBsTab('categories', this)">
<?= tr('bs_tab_categories') ?>
<?php if (!$hasCategories): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
</button>
<button class="bs-tab-btn" onclick="switchBsTab('rules', this)"><?= tr('bs_tab_rules') ?></button>
<button class="bs-tab-btn" onclick="switchBsTab('salaries', this)">
<?= tr('bs_tab_salaries') ?>
<?php if (!$hasSalaries): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
</button>
<button class="bs-tab-btn" onclick="switchBsTab('csv', this)"><?= tr('bs_tab_csv') ?></button>
</div>
<div class="bs-content">
<?php if (!isset($isBudgetSetupOk) || !$isBudgetSetupOk || !$hasBudgetItems): ?>
<div class="bs-onboarding-card">
<h4 class="bs-onboarding-title">
<span>🚀</span> <?= tr('bs_onb_title') ?>
</h4>
<p class="bs-onboarding-desc"><?= tr('bs_onb_desc') ?></p>
<ul class="bs-onboarding-list">
<li>
<strong class="<?= $hasCategories ? 'text-success' : 'text-danger' ?>"><?= $hasCategories ? '✅' : '❌' ?></strong>
<a class="bs-onboarding-link" onclick="switchBsTab('categories', document.querySelectorAll('.bs-tab-btn')[1]); return false;"><?= tr('bs_onb_cat_link') ?></a> : <?= tr('bs_onb_cat_desc') ?>
</li>
<li>
<strong class="<?= $hasAccounts ? 'text-success' : 'text-danger' ?>"><?= $hasAccounts ? '✅' : '❌' ?></strong>
<a class="bs-onboarding-link" onclick="switchBsTab('accounts', document.querySelectorAll('.bs-tab-btn')[0]); return false;"><?= tr('bs_onb_acc_link') ?></a> : <?= tr('bs_onb_acc_desc') ?>
</li>
<li>
<strong class="<?= $hasSalaries ? 'text-success' : 'text-danger' ?>"><?= $hasSalaries ? '✅' : '❌' ?></strong>
<a class="bs-onboarding-link" onclick="switchBsTab('salaries', document.querySelectorAll('.bs-tab-btn')[3]); return false;"><?= tr('bs_onb_sal_link') ?></a> : <?= tr('bs_onb_sal_desc') ?>
</li>
<li style="margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(180, 83, 9, 0.3);">
<strong class="<?= $hasBudgetItems ? 'text-success' : 'text-warning' ?>" style="color: <?= $hasBudgetItems ? '' : '#f59e0b' ?>"><?= $hasBudgetItems ? '✅' : '⏳' ?></strong>
<a href="?tab=recap" onclick="closeBudgetSettings();" class="bs-onboarding-link"><?= tr('bs_onb_items_link') ?></a> : <?= tr('bs_onb_items_desc') ?>
</li>
</ul>
</div>
<?php endif; ?>
<div id="pane-accounts" class="bs-pane active">
<div class="bs-pane-header">
<h4 class="bs-section-title"><?= tr('bs_tab_accounts') ?></h4>
<div class="bs-currency-selector">
<span><?= tr('bs_currency_label') ?> :</span>
<select id="bs-currency-select" class="pf-input bs-currency-select" onchange="updateBudgetCurrency(this)">
<option value="€">EUR (€)</option>
<option value="$">USD ($)</option>
<option value="£">GBP (£)</option>
<option value="CHF">CHF</option>
</select>
</div>
</div>
<div id="bs-list-accounts" class="bs-list-container">⏳ <?= tr('loading') ?></div>
<hr class="bs-divider">
<h5 class="bs-section-subtitle"><?= tr('bs_add_account_title') ?></h5>
<form id="form-add-account" class="bs-form-inline">
<input type="text" name="name" class="pf-input bs-input-flex" placeholder="<?= tr('bs_account_name_ph') ?>" required>
<select name="type" class="pf-input bs-input-fixed">
<option value="checking"><?= tr('bs_type_checking') ?></option>
<option value="savings"><?= tr('bs_type_savings') ?></option>
</select>
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-categories" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_categories') ?></h4>
<div id="bs-list-categories" class="bs-list-container">⏳ <?= tr('loading') ?></div>
<hr class="bs-divider">
<h5 class="bs-section-subtitle"><?= tr('bs_add_category_title') ?></h5>
<form id="form-add-category" class="bs-form-inline align-center">
<input type="text" name="code" class="pf-input bs-input-fixed" placeholder="<?= tr('bs_cat_code_ph') ?>" required>
<input type="text" name="label" class="pf-input bs-input-flex" placeholder="<?= tr('bs_cat_label_ph') ?>" required>
<select name="type" class="pf-input bs-input-fixed">
<option value="Expense"><?= tr('bs_type_expense') ?></option>
<option value="Income"><?= tr('bs_type_income') ?></option>
<option value="Savings"><?= tr('bs_type_savings_cat') ?></option>
</select>
<select name="icon" class="pf-input bs-input-icon">
<option value="📌">📌</option><option value="🛒">🛒</option><option value="🥖">🥖</option>
<option value="🍽️">🍽️</option><option value="⛽">⛽</option><option value="🚗">🚗</option>
<option value="🚆">🚆</option><option value="🏠">🏠</option><option value="⚡">⚡</option>
<option value="💧">💧</option><option value="⚕️">⚕️</option><option value="🎒">🎒</option>
<option value="👕">👕</option><option value="📱">📱</option><option value="🎮">🎮</option>
<option value="✈️">✈️</option><option value="🎁">🎁</option><option value="🐶">🐶</option>
<option value="💵">💵</option><option value="🐷">🐷</option><option value="📈">📈</option>
</select>
<input type="color" name="color" value="#3b82f6" class="bs-input-color">
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-rules" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_rules') ?></h4>
<input type="text" id="input-search-rules" class="bs-rule-search" placeholder="<?= tr('bs_rule_search_ph') ?>" onkeyup="filterRules()">
<div id="bs-list-rules" class="bs-list-container">⏳ <?= tr('loading') ?></div>
<hr class="bs-divider">
<h5 class="bs-section-subtitle"><?= tr('bs_add_rule_title') ?></h5>
<form id="form-add-rule" class="bs-form-inline align-center">
<input type="text" name="keyword" class="pf-input bs-input-flex" placeholder="<?= tr('bs_rule_keyword_ph') ?>" required>
<select name="category" id="select-rule-category" class="pf-input bs-input-fixed" required></select>
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
</form>
</div>
<div id="pane-salaries" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_tab_salaries') ?> (<?= date('Y') ?>)</h4>
<div id="bs-list-salaries" class="bs-list-container">⏳ <?= tr('loading') ?></div>
</div>
<div id="pane-csv" class="bs-pane">
<h4 class="bs-section-title"><?= tr('bs_csv_title') ?></h4>
<p class="pf-muted-tiny bs-desc"><?= tr('bs_csv_desc') ?></p>
<div id="csv-drop-zone" class="bs-csv-dropzone">
<div class="bs-csv-icon">📥</div>
<h5 class="bs-csv-title"><?= tr('bs_csv_drop_title') ?></h5>
<p class="pf-muted-tiny bs-csv-subtitle"><?= tr('bs_csv_drop_desc') ?></p>
<input type="file" id="csv_file_input" accept=".csv" style="display: none;">
</div>
<div id="csv-preview-container" class="bs-csv-preview"></div>
<h5 class="bs-section-subtitle bordered"><?= tr('bs_csv_settings_title') ?></h5>
<form id="form-csv-mapping" onsubmit="saveCsvMapping(event)">
<div class="bs-grid-2">
<div>
<label class="pf-label"><?= tr('bs_csv_delimiter') ?></label>
<select name="csv_delimiter" id="csv_delimiter" class="pf-input">
<option value=";"><?= tr('bs_csv_delim_semi') ?></option>
<option value=","><?= tr('bs_csv_delim_comma') ?></option>
<option value="\t"><?= tr('bs_csv_delim_tab') ?></option>
</select>
</div>
<div>
<label class="pf-label"><?= tr('bs_csv_date_format') ?></label>
<select name="csv_date_format" id="csv_date_format" class="pf-input">
<option value="d/m/Y">JJ/MM/AAAA</option>
<option value="Y-m-d">AAAA-MM-JJ</option>
</select>
</div>
</div>
<h5 class="bs-section-subtitle">
<?= tr('bs_csv_col_index') ?>
<small class="bs-text-muted-normal"><?= tr('bs_csv_col_index_help') ?></small>
</h5>
<div class="bs-grid-2">
<div>
<label class="pf-label"><?= tr('bs_csv_col_date') ?></label>
<input type="number" min="0" name="csv_col_date" id="csv_col_date" class="pf-input" value="0" required>
</div>
<div>
<label class="pf-label"><?= tr('bs_csv_col_label') ?></label>
<input type="number" min="0" name="csv_col_label" id="csv_col_label" class="pf-input" value="1" required>
</div>
</div>
<div class="bs-card-gray">
<label class="pf-label"><?= tr('bs_csv_amount_mgmt') ?></label>
<select name="csv_amount_type" id="csv_amount_type" class="pf-input bs-input-mb" onchange="toggleCsvAmountCols(this.value)">
<option value="single"><?= tr('bs_csv_amount_single') ?></option>
<option value="split"><?= tr('bs_csv_amount_split') ?></option>
</select>
<div class="bs-grid-2">
<div>
<label class="pf-label" id="lbl_col_debit"><?= tr('bs_csv_col_amount') ?></label>
<input type="number" min="0" name="csv_col_debit" id="csv_col_debit" class="pf-input" value="8" required>
</div>
<div id="wrapper_col_credit" style="display:none;">
<label class="pf-label"><?= tr('bs_csv_col_credit') ?></label>
<input type="number" min="0" name="csv_col_credit" id="csv_col_credit" class="pf-input" value="9">
</div>
</div>
</div>
<div class="bs-input-mb">
<label class="pf-label"><?= tr('bs_csv_col_ref') ?></label>
<input type="number" min="0" name="csv_col_ref" id="csv_col_ref" class="pf-input" value="3">
</div>
<div class="bs-text-right">
<button type="submit" class="btn btn-secondary">💾 <?= tr('btn_save_format') ?></button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tabsContainer = document.querySelector('.budget-tabs-container');
const activeTab = document.querySelector('.tab-item.active');
if (tabsContainer && activeTab) {
if (tabsContainer.scrollWidth > tabsContainer.clientWidth) {
const scrollPos = activeTab.offsetLeft - (tabsContainer.offsetWidth / 2) + (activeTab.offsetWidth / 2);
tabsContainer.scrollTo({ left: scrollPos, behavior: 'smooth' });
}
}
});
function openBudgetSettings() {
document.getElementById('modal-budget-settings').classList.add('show');
document.body.classList.add('no-scroll');
loadBudgetSettingsData();
}
function closeBudgetSettings() {
document.getElementById('modal-budget-settings').classList.remove('show');
document.body.classList.remove('no-scroll');
window.location.reload();
}
document.getElementById('modal-budget-settings')?.addEventListener('click', (e) => {
if (e.target.id === 'modal-budget-settings') closeBudgetSettings();
});
function switchBsTab(tabId, btnEl) {
document.querySelectorAll('.bs-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.bs-pane').forEach(p => p.classList.remove('active'));
btnEl.classList.add('active');
document.getElementById('pane-' + tabId).classList.add('active');
}
async function loadBudgetSettingsData() {
try {
const response = await pachaFetch('/modules/budget/includes/api/settings.php?action=get_all', { method: 'GET' });
if (!response.success) throw new Error(response.error || tr('error_occured'));
renderAccounts(response.data.accounts);
renderCategories(response.data.categories);
populateRuleCategories(response.data.categories);
renderRules(response.data.rules);
renderSalaries(response.data.salaries, response.data.year);
const currencySelect = document.getElementById('bs-currency-select');
if (currencySelect && response.data.currency) currencySelect.value = response.data.currency;
} catch (err) {
console.error("Erreur chargement paramètres :", err);
const errorMsg = `<div class="pf-alert pf-alert--error bs-input-mb">❌ ${tr('error_loading_settings')} : ${err.message}</div>`;
['accounts', 'categories', 'rules', 'salaries'].forEach(id => {
const el = document.getElementById('bs-list-' + id);
if (el) el.innerHTML = errorMsg;
});
}
}
// 1. ONGLET : COMPTES
function renderAccounts(accounts) {
const container = document.getElementById('bs-list-accounts');
if (!container) return;
if (!accounts || accounts.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_accounts')}</em>`;
return;
}
let html = '';
accounts.forEach(acc => {
const typeBadge = acc.account_type === 'checking' ? tr('bs_type_checking') : tr('bs_type_savings');
const defaultBadge = acc.is_default == 1 ? `<span class="bs-badge-default">${tr('bs_badge_default')}</span>` : '';
const ownerText = acc.owner_name ? `${tr('bs_owner')} : ${acc.owner_name}` : tr('bs_shared_account');
html += `
<div class="bs-list-item">
<div>
<strong>${acc.name}</strong> ${defaultBadge}<br>
<small class="text-muted">${typeBadge} · ${ownerText}</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action delete" onclick="deleteAccount(${acc.id})" title="${tr('btn_delete')}">🗑️</button>
</div>
</div>`;
});
container.innerHTML = html;
}
// 2. ONGLET : CATÉGORIES
function renderCategories(categories) {
const container = document.getElementById('bs-list-categories');
if (!container) return;
if (!categories || categories.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_categories')}</em>`;
return;
}
// Tri JS pour respecter l'ordre au chargement de la modale
categories.sort((a, b) => {
if (a.type !== b.type) return a.type === 'Income' ? -1 : 1;
return (a.sort_order || 0) - (b.sort_order || 0);
});
let html = '';
categories.forEach(cat => {
let typeLabel = cat.type === 'Income' ? tr('bs_type_income') : cat.type === 'Expense' ? tr('bs_type_expense') : tr('bs_type_savings_cat');
html += `
<div class="bs-list-item" data-id="${cat.id}" style="border-left: 4px solid ${cat.color || '#ccc'};">
<div>
<span style="font-size:1.2rem; margin-right:8px;">${cat.icon || '📌'}</span>
<strong>${cat.label}</strong> <small class="text-muted">(${cat.code} — ${typeLabel})</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action" onclick="moveCatUp(this)" title="Monter">⬆️</button>
<button type="button" class="btn-icon-action" onclick="moveCatDown(this)" title="Descendre">⬇️</button>
<button type="button" class="btn-icon-action delete" onclick="deleteCategory(${cat.id})" title="${tr('btn_delete')}">🗑️</button>
</div>
</div>`;
});
container.innerHTML = html;
}
// Nouvelles fonctions pour réordonner visuellement et sauvegarder
function moveCatUp(btn) {
let row = btn.closest('.bs-list-item');
if (row.previousElementSibling) {
row.parentNode.insertBefore(row, row.previousElementSibling);
saveCategoriesOrder();
}
}
function moveCatDown(btn) {
let row = btn.closest('.bs-list-item');
if (row.nextElementSibling) {
row.parentNode.insertBefore(row.nextElementSibling, row);
saveCategoriesOrder();
}
}
async function saveCategoriesOrder() {
let ids = Array.from(document.querySelectorAll('#bs-list-categories .bs-list-item')).map(el => el.dataset.id);
let fd = new FormData();
fd.append('action', 'save_categories_order');
fd.append('order', JSON.stringify(ids));
try {
await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd });
} catch (err) {
console.error("Erreur de sauvegarde de l'ordre :", err);
}
}
// 3. ONGLET : RÈGLES D'IMPORT
function renderRules(rules) {
const container = document.getElementById('bs-list-rules');
if (!container) return;
if (!rules || rules.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_rules')}</em>`;
return;
}
const groupedRules = {};
rules.forEach(r => {
const catName = r.cat_label || r.category;
if (!groupedRules[catName]) groupedRules[catName] = [];
groupedRules[catName].push(r);
});
let html = '';
for (const [catName, catRules] of Object.entries(groupedRules)) {
html += `
<details class="pf-accordion js-rule-group" data-catname="${catName.toLowerCase()}">
<summary class="pf-accordion-summary bs-accordion-summary">
<span>📁 ${catName}</span>
<span class="bs-accordion-count">${catRules.length}</span>
</summary>
<div class="pf-accordion-content" style="padding: 0.5rem;">
<div class="bs-rule-tags">`;
catRules.forEach(r => {
html += `<div class="bs-rule-tag js-rule-tag" data-keyword="${r.keyword.toLowerCase()}">
${r.keyword}
<button type="button" class="bs-rule-tag-del" onclick="deleteRule(${r.id})" title="${tr('btn_delete')}">×</button>
</div>`;
});
html += ` </div>
</div>
</details>`;
}
container.innerHTML = html;
}
function filterRules() {
const searchVal = document.getElementById('input-search-rules').value.toLowerCase();
document.querySelectorAll('.js-rule-group').forEach(group => {
let hasVisibleTag = false;
const catName = group.getAttribute('data-catname');
group.querySelectorAll('.js-rule-tag').forEach(tag => {
const keyword = tag.getAttribute('data-keyword');
if (keyword.includes(searchVal)) { tag.style.display = 'inline-flex'; hasVisibleTag = true; }
else { tag.style.display = 'none'; }
});
if (catName.includes(searchVal) && searchVal.length > 0) {
group.querySelectorAll('.js-rule-tag').forEach(tag => tag.style.display = 'inline-flex');
hasVisibleTag = true;
}
if (hasVisibleTag) {
group.style.display = 'block';
if (searchVal.length > 0) group.setAttribute('open', '');
} else {
group.style.display = 'none'; group.removeAttribute('open');
}
});
}
// 5. ONGLET : SALAIRES
function renderSalaries(salaries, year) {
const container = document.getElementById('bs-list-salaries');
if (!container) return;
if (!salaries || salaries.length === 0) {
container.innerHTML = `<em class="text-muted">${tr('bs_empty_salaries')} ${year}.</em>`;
return;
}
const currency = '<?= CURRENCY ?>';
let html = `<p class="bs-desc">${tr('bs_salaries_year_desc')} <strong>${year}</strong>.</p>`;
salaries.forEach(s => {
html += `
<div class="bs-list-item" id="salary-item-${s.id}">
<div>
<strong>${s.person}</strong><br>
<small class="text-muted">
${tr('bs_salary_net')} : ${s.salary} ${currency}<br>
${tr('bs_salary_fees')} : ${s.mensualite} ${currency}
</small>
</div>
<div class="pf-flex-gap-8">
<button type="button" class="btn-icon-action edit" data-id="${s.id}" data-person="${s.person}" data-salary="${s.salary}" data-mensualite="${s.mensualite}" onclick="inlineEditSalary(this)" title="${tr('btn_edit')}">✏️</button>
</div>
</div>`;
});
container.innerHTML = html;
}
// --- CRUD ACTIONS ---
async function deleteAccount(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete'))) return; try { const fd = new FormData(); fd.append('action', 'delete_account'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
const formAddAccount = document.getElementById('form-add-account');
if (formAddAccount) formAddAccount.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddAccount.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddAccount); fd.append('action', 'add_account'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddAccount.reset(); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
async function deleteCategory(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete_cat'))) return; try { const fd = new FormData(); fd.append('action', 'delete_category'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
const formAddCategory = document.getElementById('form-add-category');
if (formAddCategory) formAddCategory.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddCategory.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddCategory); fd.append('action', 'add_category'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddCategory.reset(); formAddCategory.querySelector('input[type="color"]').value = "#3b82f6"; await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
function populateRuleCategories(categories) { const select = document.getElementById('select-rule-category'); if (!select) return; select.innerHTML = ''; if (!categories) return; categories.forEach(cat => { const opt = document.createElement('option'); opt.value = cat.code; opt.textContent = `${cat.icon || ''} ${cat.label}`; select.appendChild(opt); }); }
async function deleteRule(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete_rule'))) return; try { const fd = new FormData(); fd.append('action', 'delete_rule'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
const formAddRule = document.getElementById('form-add-rule');
if (formAddRule) formAddRule.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddRule.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddRule); fd.append('action', 'add_rule'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddRule.reset(); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
function inlineEditSalary(btnEl) {
const id = btnEl.dataset.id; const person = btnEl.dataset.person; const salary = btnEl.dataset.salary; const mensualite = btnEl.dataset.mensualite; const currency = '<?= CURRENCY ?>';
document.getElementById(`salary-item-${id}`).innerHTML = `
<form onsubmit="submitInlineSalary(event, ${id})" class="bs-form-inline align-center">
<div class="bs-input-flex"><strong>${person}</strong></div>
<div style="display:flex; align-items:center; gap:4px;"><small class="text-muted">${tr('bs_salary_net')}:</small><input type="number" step="0.01" name="salary" class="pf-input bs-input-fixed" value="${salary}" required> ${currency}</div>
<div style="display:flex; align-items:center; gap:4px;"><small class="text-muted">${tr('bs_salary_fees')}:</small><input type="number" step="0.01" name="mensualite" class="pf-input bs-input-fixed" value="${mensualite}" required> ${currency}</div>
<button type="submit" class="btn btn-secondary">💾</button>
<button type="button" class="btn btn-ghost" onclick="loadBudgetSettingsData()">❌</button>
</form>`;
}
async function submitInlineSalary(event, id) { event.preventDefault(); const form = event.target; const btn = form.querySelector('button'); btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(form); fd.append('action', 'save_salary'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); btn.innerText = '💾'; btn.disabled = false; } }
async function updateBudgetCurrency(selectEl) { try { const fd = new FormData(); fd.append('action', 'save_currency'); fd.append('currency', selectEl.value); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); if (typeof showToast === 'function') { showToast(`${tr('bs_currency_updated')} ✅`); } await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
// CSV
function toggleCsvAmountCols(type) { if (type === 'split') { document.getElementById('wrapper_col_credit').style.display = 'block'; document.getElementById('lbl_col_debit').innerText = tr('bs_csv_col_debit'); } else { document.getElementById('wrapper_col_credit').style.display = 'none'; document.getElementById('lbl_col_debit').innerText = tr('bs_csv_col_amount'); } }
function renderCsvMapping(mapping) { if (!mapping) return; document.getElementById('csv_delimiter').value = mapping.delimiter || ';'; document.getElementById('csv_date_format').value = mapping.date_format || 'd/m/Y'; document.getElementById('csv_col_date').value = mapping.col_date ?? 0; document.getElementById('csv_col_label').value = mapping.col_label ?? 1; document.getElementById('csv_col_ref').value = mapping.col_ref ?? 3; const amountType = mapping.amount_type || 'split'; document.getElementById('csv_amount_type').value = amountType; document.getElementById('csv_col_debit').value = mapping.col_debit ?? 8; document.getElementById('csv_col_credit').value = mapping.col_credit ?? 9; toggleCsvAmountCols(amountType); }
async function saveCsvMapping(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳...'; btn.disabled = true; try { const fd = new FormData(form); fd.append('action', 'save_csv_mapping'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); if (typeof showToast === 'function') showToast(tr('bs_csv_saved')); else alert(tr('bs_csv_saved')); } catch (err) { alert(tr('error_occured') + " : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } }
const dropZone = document.getElementById('csv-drop-zone'); const fileInput = document.getElementById('csv_file_input');
if (dropZone && fileInput) {
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.backgroundColor = 'rgba(0, 123, 255, 0.05)'; dropZone.style.borderColor = 'var(--primary-color, #007bff)'; });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.style.backgroundColor = ''; dropZone.style.borderColor = 'var(--border-light, #ccc)'; });
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.style.backgroundColor = ''; dropZone.style.borderColor = 'var(--border-light, #ccc)'; if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; handleCsvUpload(e.dataTransfer.files[0]); } });
fileInput.addEventListener('change', function() { if (this.files.length) { handleCsvUpload(this.files[0]); } });
}
function handleCsvUpload(file) {
if (!file.name.endsWith('.csv')) { alert(tr('bs_csv_err_type')); return; }
if (typeof showToast === 'function') { showToast(`${tr('bs_csv_file_ok')} : ${file.name}`); }
const reader = new FileReader();
reader.onload = function(e) {
const text = e.target.result; const delimiter = document.getElementById('csv_delimiter').value || ';';
const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
if (lines.length > 0) {
const headers = lines[0].split(delimiter).map(h => h.replace(/^"|"$/g, '').trim());
const sampleRows = [];
for (let i = 1; i < Math.min(lines.length, 3); i++) { sampleRows.push(lines[i].split(delimiter).map(d => d.replace(/^"|"$/g, '').trim())); }
renderCsvTablePreview(headers, sampleRows);
}
};
reader.readAsText(file, 'ISO-8859-1');
}
function renderCsvTablePreview(headers, sampleRows) {
const container = document.getElementById('csv-preview-container');
let html = `<h5 class="bs-section-subtitle">👁️ ${tr('bs_csv_preview_title')}</h5>`;
html += `<div class="bs-csv-table-wrapper"><table class="bs-csv-table"><thead class="bs-csv-thead"><tr>`;
headers.forEach((header, index) => {
const cleanHeader = header || `(${tr('bs_csv_empty_col')})`;
html += `<th class="bs-csv-th"><div class="bs-csv-th-index">N° ${index}</div><div>${cleanHeader}</div></th>`;
});
html += `</tr></thead><tbody>`;
if (sampleRows.length === 0) { html += `<tr><td colspan="${headers.length}" class="bs-csv-td-empty">${tr('bs_csv_no_data')}</td></tr>`; }
else { sampleRows.forEach(row => { html += `<tr class="bs-csv-tr">`; for (let i = 0; i < headers.length; i++) { const cellData = row[i] !== undefined ? row[i] : ''; const displayData = cellData.length > 40 ? cellData.substring(0, 40) + '...' : cellData; html += `<td class="bs-csv-td">${displayData}</td>`; } html += `</tr>`; }); }
html += `</tbody></table></div>`;
container.innerHTML = html; container.style.display = 'block';
}
</script>
+267 -117
View File
@@ -57,19 +57,29 @@ if (isset($_POST['action']) && $_POST['action'] === 'reopen_month') {
exit;
}
if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
$count = 0;
$stmtExp = $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$stmtRule = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category) VALUES (?, ?) ON DUPLICATE KEY UPDATE category = VALUES(category)");
$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, 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'])) {
foreach ($_POST['lines'] as $line) {
if (isset($line['import_check'])) {
$cat = $line['cat'];
$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;
$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'];
try {
$stmtExp->execute([$dateToSave, $gestionMonthLine, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId]);
$stmtRule->execute([$line['label'], $cat]);
$stmtExp->execute([$dateToSave, $gestionMonthLine, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId, $salaryId]);
$stmtRule->execute([$line['label'], $cat, $budgetItemId]);
$count++;
} catch (Exception $e) { continue; }
}
@@ -110,7 +120,15 @@ $showPreview = false;
if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) {
$file = $_FILES['csv_file']['tmp_name'];
$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){}
fgetcsv($handle, 1000, ";", "\"", "\\");
@@ -129,18 +147,59 @@ if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) {
$isDuplicate = in_array($uniqueKey, $existingRefs);
$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);
$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->execute([$viewMonthDate]);
$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');
$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;
$reste_a_venir_calc = 0;
$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)) {
$absAmount = abs((float)$item['amount']);
$amt = ($item['type'] === 'Annuel') ? $absAmount / 12 : $absAmount;
$name = trim($item['name']);
$catCode = $item['category'];
if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) {
$fixedChargesList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount];
}
if ($item['category'] === 'income') {
$isIncome = (strtoupper($catCode) === 'INCOME');
if ($isIncome) {
$incomeList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount];
$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 {
$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;
if (in_array($item['id'], $paidItemIds)) $isPaid = true;
elseif (!empty($item['mapping_keywords'])) {
@@ -204,83 +280,112 @@ while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
}
if (!$isPaid) {
$reste_a_venir_calc += $absAmount;
$pending_charges[] = ['name' => $name, 'amount' => $absAmount];
// Ajouté dans le tableau avec un tooltip vide
$pending_charges[] = ['name' => $name, 'amount' => $absAmount, 'tooltip' => 'Charge fixe en attente'];
}
}
// 2. C'est une estimation (Variable)
if ((int)$item['is_estimate'] === 1) {
$estimatesList[] = [
'name' => $name,
'amount' => $amt,
'categories' => !empty($catCode) ? array_map('trim', explode(',', $catCode)) : []
];
}
// 3. Attribution du budget aux jauges visuelles de la page
// Pour ne pas tout casser, on donne tout le plafond visuel à la 1ère catégorie de la liste
if (!empty($catCode)) {
$catCodesArray = array_map('trim', explode(',', $catCode));
$firstCat = $catCodesArray[0];
if (isset($categoriesConfig[$firstCat])) {
$categoriesConfig[$firstCat]['budget'] += $amt;
}
}
if ($name === 'Estimacio F&B & beauty') $budget_fmcg = $amt;
elseif ($name === 'Estimacio escola') $budget_school = $amt;
elseif ($name === 'Estimation gasolina') $budget_essence = $amt;
elseif ((int)$item['is_estimate'] === 0 && $item['type'] === 'Mensuel' && $item['category'] === 'expense') { $budget_frais += $absAmount; }
}
}
$budget_autres = max(0, $total_income - $total_expenses_prevues);
$stmtSalaries = $pdo->query("SELECT id, person, salary FROM pf_salary_config WHERE year = " . $viewY);
while ($sal = $stmtSalaries->fetch(PDO::FETCH_ASSOC)) {
$incomeList[] = [
'id' => 'SAL_' . $sal['id'],
'name' => 'Salaire ' . $sal['person'],
'amount' => (float)$sal['salary']
];
}
// TRADUCTION DES CATÉGORIES
$categoriesConfig = [
'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']]
];
// L'enveloppe "Autres" prend tout le reste du budget non alloué
$categoriesConfig['AUTRES']['budget'] = max(0, $total_income - $total_expenses_prevues);
$totals = array_fill_keys(array_keys($categoriesConfig), 0);
$expensesByCategory = array_fill_keys(array_keys($categoriesConfig), []);
$total_rentrees = 0;
$depenses_reelles = 0;
// VENTILATION DYNAMIQUE DES DÉPENSES
foreach ($allExpenses as $exp) {
$cat = $exp['category'];
if (!isset($totals[$cat])) $cat = 'Autres';
if (!isset($categoriesConfig[$cat])) $cat = 'AUTRES';
$val = (float)$exp['amount'];
if ($cat === 'Income') { $totals[$cat] += $val; }
else {
if ($val > 0) $categoriesConfig[$cat]['budget'] += $val;
if ($categoriesConfig[$cat]['db_type'] === 'Income') {
$totals[$cat] += $val;
} else {
if ($val > 0) $categoriesConfig[$cat]['budget'] += $val; // Remboursement
else $totals[$cat] += abs($val);
}
$expensesByCategory[$cat][] = $exp;
if ($val > 0) {
if ($cat === 'Income' || $cat !== 'Frais') $total_rentrees += $val;
else $depenses_reelles -= $val;
if ($categoriesConfig[$cat]['db_type'] === 'Income') { $total_rentrees += $val; }
else { $depenses_reelles -= $val; }
} else {
$depenses_reelles += abs($val);
}
}
// Reste à venir dynamiques
$ecole_depense = isset($totals['School']) ? $totals['School'] : 0;
$reste_ecole = max(0, $budget_school - $ecole_depense);
if ($reste_ecole > 0) {
$reste_a_venir_calc += $reste_ecole;
$pending_charges[] = ['name' => tr('bud_rem_school'), 'amount' => $reste_ecole];
}
$fmcg_depense = isset($totals['FMCG']) ? $totals['FMCG'] : 0;
$reste_fmcg = max(0, $budget_fmcg - $fmcg_depense);
if ($reste_fmcg > 0) {
$reste_a_venir_calc += $reste_fmcg;
$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];
// CALCUL DES RESTES À VENIR BASÉ SUR LES ESTIMATIONS (MACRO)
foreach ($estimatesList as $est) {
$spentForEstimate = 0;
$detailsHover = [];
// On additionne les dépenses de toutes les catégories liées à cette estimation
foreach ($est['categories'] as $cCode) {
if (!empty($cCode) && isset($totals[$cCode])) {
$spentForEstimate += $totals[$cCode];
// On prépare le détail pour la petite bulle d'info (hover)
if ($totals[$cCode] > 0 && isset($categoriesConfig[$cCode])) {
$detailsHover[] = strip_tags($categoriesConfig[$cCode]['label']) . " : " . number_format($totals[$cCode], 0) . "";
}
}
}
$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';
$pending_charges[] = [
'name' => 'Reste ' . $est['name'],
'amount' => $rem,
'tooltip' => $tooltip
];
}
}
// 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;
$salaires_retenus = max($rentrees_salaires_reels, $budget_income_prevu);
$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) {
$solde_actuel = $monthState['solde_actuel'];
@@ -310,7 +415,6 @@ function getDisplayLogic($spent, $bg, $type) {
return ['pct' => $pct, 'isOver' => $isOver, 'text' => $text];
}
// Noms des mois traduits
$monthNames = [
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'),
@@ -396,7 +500,7 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div>
<?php else: ?>
<?php foreach($pending_charges as $pc): ?>
<div style="display:flex; justify-content:space-between; margin-bottom:6px;">
<div style="display:flex; justify-content:space-between; margin-bottom:6px;" <?= !empty($pc['tooltip']) ? 'title="'.htmlspecialchars($pc['tooltip']).'" style="cursor:help;"' : '' ?>>
<span><?= htmlspecialchars($pc['name']) ?></span>
<strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong>
</div>
@@ -480,11 +584,11 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
</div>
</div>
<?php if (!$isClosed): ?>
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes($conf['label']) ?>')"></button>
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes(strip_tags($conf['label'])) ?>')"></button>
<?php endif; ?>
</div>
<?php $barCol = ($key === 'Income') ? '#10b981' : ($logic['isOver'] ? '#ef4444' : $conf['color']); ?>
<?php $barCol = ($conf['db_type'] === 'Income') ? '#10b981' : ($logic['isOver'] ? '#ef4444' : $conf['color']); ?>
<div style="background:#f1f5f9; height:4px; width:100%;">
<div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div>
</div>
@@ -543,7 +647,7 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<label class="pf-label"><?= tr('bud_category') ?></label>
<select name="category" id="modalCatSelect" class="pf-input" onchange="handleModalCatChange(this)">
<?php foreach($categoriesConfig as $key => $conf): ?>
<option value="<?= $key ?>"><?= $conf['label'] ?></option>
<option value="<?= $key ?>"><?= strip_tags($conf['label']) ?></option>
<?php endforeach; ?>
</select>
</div>
@@ -562,27 +666,21 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<datalist id="modalSuggestions"></datalist>
</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;">
<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>
<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; ?>
</select>
</div>
<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>
<option value=""><?= tr('bud_select_beneficiary') ?> </option>
<?php foreach ($incomeList as $inc): ?><option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?></option><?php endforeach; ?>
<option value="">-- Aucun --</option>
<?php foreach ($incomeList as $inc): ?>
<option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
@@ -690,16 +788,20 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
<select name="lines[<?= $idx ?>][cat]" class="pf-input line-select" onchange="handleLineCatChange(this)" <?= $dis ?> style="padding:4px; font-size:0.85rem; flex:1;">
<option value="">-- <?= $isCrd ? tr('bud_ignore') : tr('bud_to_define') ?> --</option>
<?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; ?>
</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>
<option value="">-- <?= tr('bud_is_charge') ?> --</option>
<?php foreach ($fixedChargesList as $fc): ?><option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['name']) ?></option><?php endforeach; ?>
<option value="">-- Lier à une Charge Fixe --</option>
<?php foreach ($fixedChargesList as $fc): ?>
<option value="<?= $fc['id'] ?>" <?= ($row['suggested_item_id'] == $fc['id']) ? 'selected' : '' ?>><?= htmlspecialchars($fc['name']) ?></option>
<?php endforeach; ?>
</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>
<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>
</div>
</td>
@@ -723,7 +825,6 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
document.body.classList.add('no-scroll');
<?php endif; ?>
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
@@ -735,34 +836,46 @@ window.I18N = {
'btn_delete': <?= json_encode(tr('btn_delete')) ?>,
};
// --- DICTIONNAIRE JS DYNAMIQUE ---
const catConfigs = <?= json_encode($categoriesConfig) ?>;
const activeViewMonth = '<?= substr($viewMonthDate, 0, 7) ?>';
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 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) {
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('blockInputSelect').style.display = 'none';
document.getElementById('blockInputText').style.display = 'block';
document.getElementById('blockInputFrais').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('incomeSelect').required = false;
document.getElementById('fraisSelect').disabled = true;
document.getElementById('incomeSelect').disabled = true;
if (catKey === 'School') { document.getElementById('blockInputSelect').style.display = 'block'; }
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; }
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; }
else {
document.getElementById('blockInputText').style.display = 'block'; document.getElementById('modalLabelInput').required = true;
const list = document.getElementById('modalSuggestions'); list.innerHTML = '';
if (suggestions[catKey]) { suggestions[catKey].forEach(i => { const op = document.createElement('option'); op.value = i; list.appendChild(op); }); }
if (conf.db_type === 'Income') {
const incSel = document.getElementById('incomeSelect');
// 👁️ Ne s'affiche que s'il y a des revenus prévus à lier, sinon reste invisible
if (incSel.options.length > 1) {
document.getElementById('blockInputIncome').style.display = 'block';
incSel.disabled = false;
}
}
else if (catKey === 'FIXED') { // 🔒 Strictement pour les Charges Fixes
document.getElementById('blockInputFrais').style.display = 'block';
document.getElementById('fraisSelect').disabled = false;
document.getElementById('fraisSelect').required = true; // 🔥 Devient OBLIGATOIRE
}
const list = document.getElementById('modalSuggestions'); list.innerHTML = '';
if (conf.suggestions) {
conf.suggestions.forEach(i => { const op = document.createElement('option'); op.value = i; list.appendChild(op); });
}
}
@@ -774,8 +887,10 @@ function openAddModal(catKey, catLabel) {
document.getElementById('modalGestionMonth').value = activeViewMonth;
document.getElementById('modalLabelInput').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) {
@@ -787,17 +902,35 @@ function openEditModal(e) {
document.getElementById('modalLabelInput').value = e.label;
document.getElementById('modalIsCredit').value = parseFloat(e.amount) > 0 ? "1" : "0";
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;
else if (e.category === 'Income') document.getElementById('incomeSelect').value = e.budget_item_id;
const catSelect = document.getElementById('modalCatSelect'); catSelect.value = e.category;
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 fSel = row.querySelector('.select-frais'); const iSel = row.querySelector('.select-income');
fSel.style.display = 'none'; iSel.style.display = 'none'; fSel.value = ''; iSel.value = ''; fSel.disabled = true; iSel.disabled = true;
if (select.value === 'Frais') { fSel.style.display = 'block'; fSel.disabled = false; }
else if (select.value === 'Income') { iSel.style.display = 'block'; iSel.disabled = false; }
const fSel = row.querySelector('.select-frais');
const iSel = row.querySelector('.select-income');
const catKey = select.value.toUpperCase();
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();
}
@@ -806,27 +939,45 @@ function toggleAll(src) { document.querySelectorAll('.line-checkbox:not([disable
function checkValidation() {
let miss = 0;
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;
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 row = cb.closest('tr');
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 = '';
});
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'] || ''); }
else { btn.disabled = false; btn.style.opacity=1; msg.style.display='none'; }
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'; }
}
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) => {
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');
}
});
// --- 2. SUPPRESSION ASYNCHRONE ---
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;
const formData = new FormData();
@@ -856,7 +1007,6 @@ document.addEventListener('DOMContentLoaded', () => {
submitBtn.innerText = '⏳ ...';
const formData = new FormData(formExpense);
// Force l'action ici pour être sûr
formData.set('action', 'save_expense_manual');
const actionUrl = formExpense.getAttribute('action') || 'modules/budget/includes/api/manage-item.php';
+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 dun é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 lagenda.</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 lagenda";
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 à lURL 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 lURL 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: */*']);
}
+288 -206
View File
@@ -32,7 +32,9 @@
padding-bottom: 40px;
}
/* En-tête & Boutons */
/* =========================================================
EN-TÊTE & BOUTONS
========================================================= */
.fc-header-row {
display: flex;
justify-content: space-between;
@@ -62,7 +64,9 @@
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
/* Calendrier Mensuel */
/* =========================================================
CALENDRIER MENSUEL (Vue Grille Mois)
========================================================= */
.fc-month-calendar-wrapper {
background: white;
border: 1px solid var(--pf-border);
@@ -114,6 +118,7 @@
.fc-view-controls {
display: flex !important;
align-items: center;
width: 100% !important;
background: #f1f5f9 !important;
padding: 4px !important;
@@ -128,7 +133,7 @@
padding: 6px 16px;
font-size: 0.85rem;
font-weight: 600;
color: var(--pf-text-muted);
color: #64748b;
cursor: pointer;
transition: 0.2s;
}
@@ -206,7 +211,9 @@
text-transform: capitalize;
}
/* Décompte Congés (Pills) */
/* =========================================================
DÉCOMPTE CONGÉS MENSUEL (PILLS)
========================================================= */
.fc-month-balances {
display: flex;
flex-wrap: wrap;
@@ -226,12 +233,6 @@
border: 1px solid var(--pf-border);
box-shadow: var(--pf-shadow-sm);
}
.fc-minimal-balance-card strong.alex {
color: var(--text-alex);
}
.fc-minimal-balance-card strong.laia {
color: var(--text-laia);
}
.fc-minimal-chips {
display: flex;
gap: 8px;
@@ -249,17 +250,17 @@
}
.fc-min-chip .type {
font-weight: 700;
color: var(--pf-text-muted);
color: #64748b;
margin-right: 6px;
}
.fc-min-chip .val {
font-weight: 800;
color: var(--pf-text-main);
color: #0f172a;
}
.fc-used-badge {
margin-left: 6px;
background: #fee2e2;
color: var(--pf-danger);
color: #ef4444;
padding: 1px 5px;
border-radius: 10px;
font-size: 0.65rem;
@@ -272,7 +273,7 @@
font-size: 0.9rem;
display: flex;
align-items: center;
color: var(--pf-danger);
color: #ef4444;
font-weight: bold;
}
@keyframes pulseBurn {
@@ -290,7 +291,9 @@
}
}
/* Couleurs des jours */
/* =========================================================
COULEURS DES JOURS (GRILLES)
========================================================= */
.fc-day--school-holiday {
background: var(--c-school-holiday) !important;
}
@@ -304,35 +307,13 @@
.fc-day--extra-off-carole {
background: var(--c-extra-off) !important;
}
.fc-day--selected {
background: var(--c-selected) !important;
outline: 2px solid var(--pf-primary);
z-index: 5;
}
.fc-day--centre::after {
content: "🏫";
position: absolute;
top: 2px;
right: 2px;
font-size: 12px;
}
.fc-day--avis::after {
content: "";
position: absolute;
top: 2px;
right: 2px;
width: 14px;
height: 14px;
background: url("/modules/family-calendar/assets/img/avis.svg") no-repeat
center;
background-size: contain;
}
.fc-pep-sick-emoji {
position: absolute;
bottom: 2px;
right: 2px;
font-size: 12px;
}
.fc-day--off-carole.fc-day--school-holiday,
.fc-day--extra-off-carole.fc-day--school-holiday,
.fc-day--centre.fc-day--school-holiday,
@@ -340,7 +321,26 @@
box-shadow: inset 0 -8px 0 0 var(--c-school-holiday) !important;
}
/* Planning Hebo (Desktop) */
.fc-day--today {
background: #eff6ff !important;
box-shadow: inset 0 0 0 1px #bfdbfe !important;
}
.fc-day--today .fc-day-number {
display: inline;
background: none;
width: auto;
height: auto;
box-shadow: none;
border-radius: 0;
margin: 0;
color: var(--pf-primary);
font-weight: 900;
font-size: 1.1em;
}
/* =========================================================
PLANNING GLOBAL (Matrice Annuelle)
========================================================= */
.fc-week-header {
display: flex;
align-items: center;
@@ -379,22 +379,17 @@
border-spacing: 0;
}
/* =========================================================
HARMONISATION DES BORDURES ET HEADERS STICKY (1px strict)
========================================================= */
/* HARMONISATION DES BORDURES ET HEADERS STICKY */
#planningTable thead tr {
height: 30px; /* On fixe la hauteur pour un calcul parfait */
height: 30px;
}
#planningTable thead th {
position: sticky;
background: #f8fafc !important;
border: none !important; /* On supprime les bordures natives */
/* On utilise UNIQUEMENT des ombres internes pour simuler 1px de bordure sans doublement */
border: none !important;
box-shadow:
inset 0 -1px 0 var(--pf-border),
inset -1px 0 0 var(--pf-border) !important;
padding: 6px;
font-size: 0.75rem;
z-index: 20;
@@ -403,14 +398,14 @@
}
#planningTable thead tr:nth-child(1) th {
top: 0;
z-index: 22; /* Au premier plan */
z-index: 22;
}
#planningTable thead tr:nth-child(2) th {
top: 30px; /* Exactement la hauteur de la ligne 1 */
top: 30px;
z-index: 21;
}
#planningTable thead tr:nth-child(3) th {
top: 60px; /* Ligne 1 + Ligne 2 */
top: 60px;
z-index: 20;
}
@@ -425,10 +420,9 @@
color: var(--text-laia) !important;
}
/* Cellules standards du corps */
#planningTable tbody td {
position: relative;
border: none !important; /* Reset de sécurité */
border: none !important;
border-right: 1px solid var(--pf-border) !important;
border-bottom: 1px solid var(--pf-border) !important;
height: 36px;
@@ -467,13 +461,36 @@ td.col-laia-sub {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 0.7rem;
color: var(--pf-text-muted);
color: #64748b;
}
/* =========================================================
GESTION DES COLONNES STICKY (Mois & Semaine)
========================================================= */
/* ==========================================================================
OPTIMISATION DES LARGEURS DU TABLEAU HEBDOMADAIRE
========================================================================== */
/* 1. On limite les colonnes des jours (Lun-Ven) pour qu'elles prennent ~55% de la page max */
#planningTable th.col-day,
#planningTable td.col-day {
width: 11%;
max-width: 90px;
}
/* 2. On compacte la colonne "Sem." qui n'affiche plus que 2 chiffres */
#planningTable th.col-sticky-sem,
#planningTable td.col-sticky-sem {
width: 40px;
min-width: 40px;
text-align: center;
padding: 4px; /* Réduit les marges internes */
}
/* 3. On s'assure que le tableau utilise tout l'espace disponible intelligemment */
#planningTable {
table-layout: auto;
width: 100%;
}
/* GESTION DES COLONNES STICKY (Mois & Semaine) */
#planningTable tbody td.col-sticky-mois {
position: sticky !important;
left: 0 !important;
@@ -481,7 +498,6 @@ td.col-laia-sub {
background: white !important;
padding: 0 !important;
}
#planningTable tbody td.col-sticky-mois .fc-sticky-mois-label {
position: absolute;
top: 50%;
@@ -491,42 +507,31 @@ td.col-laia-sub {
letter-spacing: 1px;
text-align: center;
}
/* 2. Sticky Semaine (Body) */
#planningTable tbody td.col-sticky-sem {
position: sticky !important;
left: 54px !important;
z-index: 14 !important;
background: white !important;
/* Ombre portée pour délimiter la zone sticky du reste du tableau */
box-shadow: 4px 0 6px -2px rgba(0, 0, 0, 0.1);
}
/* 3. Sticky Mois (Header) */
#planningTable thead tr th.col-sticky-mois {
position: sticky !important;
top: 0 !important;
left: 0 !important;
z-index: 30 !important;
background: var(--pf-bg-lighter) !important;
/* Les bordures sont gérées par le box-shadow général du thead th */
}
/* 4. Sticky Semaine (Header) */
#planningTable thead tr th.col-sticky-sem {
position: sticky !important;
top: 0 !important;
left: 54px !important;
z-index: 29 !important;
background: var(--pf-bg-lighter) !important;
/* Ombre interne (bordures) + Ombre portée (effet sticky) */
box-shadow:
inset 0 -1px 0 var(--pf-border),
inset -1px 0 0 var(--pf-border),
4px 0 6px -2px rgba(0, 0, 0, 0.1) !important;
}
/* Rétablissement horizontal pour les headers mois et semaine */
#planningTable thead th.col-month,
#planningTable tbody td.col-sticky-sem {
writing-mode: horizontal-tb;
@@ -535,15 +540,15 @@ td.col-laia-sub {
text-align: center;
}
/* Panneaux bas & Légendes */
/* =========================================================
PANNEAUX BAS, LÉGENDES & RÉSUMÉS
========================================================= */
.fc-bottom-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
align-items: start;
}
/* Nouvelles bordures style "Budget" pour les conteneurs */
.pf-card {
background: white;
border: 1px solid var(--pf-border);
@@ -560,6 +565,7 @@ td.col-laia-sub {
padding-bottom: 12px;
}
/* Légendes statiques */
.pf-legend-grid {
display: flex;
flex-wrap: wrap;
@@ -607,53 +613,62 @@ td.col-laia-sub {
border: none;
}
.fc-summary-header {
/* Résumé Mensuel Injecté */
.fc-month-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--pf-border);
flex-direction: column;
gap: 8px;
}
.fc-summ-select {
.fc-month-summary-inline {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 5px;
background: var(--pf-bg-lighter, #f8fafc);
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--pf-border);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.8rem;
height: auto;
background: #f8fafc;
}
.fc-summary-item {
.fc-summ-pill {
display: flex;
justify-content: space-between;
padding: 12px 4px;
border-bottom: 1px solid var(--pf-border); /* Ligne de séparation visible */
}
.fc-summary-item:last-child {
border-bottom: none;
}
.fc-summary-label {
font-size: 0.85rem;
flex-direction: column;
align-items: center;
font-size: 0.75rem;
color: #64748b;
font-weight: 600;
flex: 1;
}
.fc-summary-value {
font-size: 1rem;
font-weight: 700;
.fc-summ-pill strong {
font-size: 0.95rem;
color: #0f172a;
margin-top: 2px;
}
.pf-presence-pill strong {
color: var(--pf-primary);
}
/* Menu Contextuel Tactile */
[data-theme="dark"] .fc-month-summary-inline {
background: #1c2128;
border-color: #30363d;
}
[data-theme="dark"] .fc-summ-pill strong {
color: #c9d1d9;
}
/* =========================================================
MENU CONTEXTUEL (COMPACT & PROPRE)
========================================================= */
#selectionMenu,
#fc-month-selectionMenu,
.fc-selection-menu {
position: absolute;
z-index: 9000;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border: 1px solid rgba(226, 232, 240, 0.8);
box-shadow: var(--pf-shadow-lg);
border-radius: 16px;
padding: 10px 12px;
min-width: 220px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
border-radius: 12px;
padding: 8px 12px;
min-width: 210px;
display: none;
animation: menuPopIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform-origin: top center;
@@ -668,88 +683,70 @@ td.col-laia-sub {
transform: scale(1) translateY(0);
}
}
.fc-menu-section {
padding: 6px 0;
border-bottom: 1px dashed #cbd5e1;
}
.fc-menu-section:last-child {
border: none;
border-bottom: none;
padding-bottom: 0;
}
.fc-menu-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.fc-menu-section strong {
font-size: 0.7rem;
text-transform: uppercase;
color: var(--pf-text-muted);
}
.fc-menu-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
gap: 6px;
}
.fc-menu-btn {
background: white;
border: 1px solid var(--pf-border);
padding: 6px 8px;
border-radius: 8px;
background: transparent;
border: 1px solid transparent;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
width: 100%;
transition: 0.2s;
box-shadow: var(--pf-shadow-sm);
text-align: left;
transition: all 0.2s ease;
color: #0f172a;
display: flex;
align-items: center;
}
.fc-menu-btn:hover {
background: var(--pf-bg-page);
border-color: var(--pf-primary);
color: var(--pf-primary);
background: #f1f5f9;
}
.fc-menu-clear-icon {
background: transparent;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
display: flex;
border-radius: 6px;
/* --- LE BOUTON ACTIF (SANS COCHE !) --- */
.fc-menu-btn--active {
border: 1px solid var(--pf-primary) !important;
background: #eff6ff !important;
color: var(--pf-primary) !important;
font-weight: 700 !important;
}
.fc-menu-clear-icon svg {
/* Sécurité absolue pour tuer les coches fantômes */
.fc-menu-btn--active::before,
.fc-menu-btn--active::after {
display: none !important;
content: none !important;
}
/* Icônes intégrées dans le JS */
.fc-icon-centre {
font-size: 1.1rem;
line-height: 1;
margin-right: 4px;
}
.fc-icon-avis {
width: 14px;
height: 14px;
}
.fc-menu-clear-icon:hover {
background: #fef2f2;
color: var(--pf-danger);
}
.fc-menu-leaves-table table {
width: 100%;
border-spacing: 2px;
}
.fc-menu-leaves-table th {
font-size: 0.7rem;
color: var(--pf-text-muted);
text-align: center;
}
.fc-th-inline {
display: flex;
justify-content: center;
gap: 4px;
object-fit: contain;
margin-right: 4px;
vertical-align: middle;
}
/* --- BOUTON DE MENU ACTIF (Élément déjà enregistré) --- */
.fc-menu-btn--active {
background: #eff6ff;
border-color: var(--pf-primary);
color: var(--pf-primary);
/* Un léger inset shadow pour rendre la bordure un peu plus épaisse sans casser la taille */
box-shadow: inset 0 0 0 1px var(--pf-primary);
}
/* Table specifique modale vacances */
/* =========================================================
MODALES (Paramètres & Vacances)
========================================================= */
.fc-holidays-table {
width: 100%;
border-collapse: collapse;
@@ -772,7 +769,6 @@ td.col-laia-sub {
background: #fafafa;
}
/* === MODALES : Alignement du Header et de la Croix === */
.pf-modal-header {
display: flex;
justify-content: space-between;
@@ -781,14 +777,12 @@ td.col-laia-sub {
border-bottom: 1px solid var(--pf-border);
padding-bottom: 15px;
}
.pf-modal-header .pf-modal-title {
margin: 0;
padding: 0;
border-bottom: none; /* On annule la bordure par défaut du global.css pour la déléguer au header */
border-bottom: none;
flex-grow: 1;
}
.pf-modal-close {
background: none;
border: none;
@@ -799,9 +793,37 @@ td.col-laia-sub {
padding: 0 0 0 15px;
transition: color 0.2s;
}
.pf-modal-close:hover {
color: #ef4444; /* Devient rouge au survol */
color: #ef4444;
}
.fc-sidebar-tab {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 1.2rem;
background: transparent;
border: none;
text-align: left;
font-weight: 600;
cursor: pointer;
color: #0f172a;
transition: all 0.2s;
width: calc(100% - 16px);
margin: 2px 8px;
border-radius: 8px;
font-size: 0.95rem;
}
.fc-sidebar-tab.active {
background: #111827;
color: #ffffff;
}
.fc-sidebar-tab:hover:not(.active) {
background: #f1f5f9;
}
[data-theme="dark"] .fc-sidebar-tab.active {
background: var(--pf-primary);
color: #ffffff;
}
/* =========================================================
@@ -811,36 +833,25 @@ td.col-laia-sub {
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-family: inherit;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-main);
/* Padding standard */
color: #0f172a;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: capitalize;
box-shadow: var(--pf-shadow-sm);
}
.fc-smart-select:hover {
border-color: #94a3b8;
}
.fc-smart-select:focus {
background: #ffffff;
border-color: var(--primary);
border-color: var(--pf-primary);
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
/* --- BOUTON AUJOURD'HUI & CONTROLES --- */
.fc-view-controls {
align-items: center; /* Pour bien centrer le nouveau bouton */
}
.fc-today-button {
background: white;
border: 1px solid var(--pf-border);
@@ -853,12 +864,10 @@ td.col-laia-sub {
transition: all 0.2s;
box-shadow: var(--pf-shadow-sm);
}
.fc-today-button:hover {
background: #eff6ff;
border-color: var(--pf-primary);
}
.fc-view-divider {
width: 1px;
height: 20px;
@@ -866,34 +875,14 @@ td.col-laia-sub {
margin: 0 8px;
}
/* --- MISE EN ÉVIDENCE DU JOUR EN COURS --- */
.fc-day--today {
background: #eff6ff !important;
box-shadow: inset 0 0 0 1px #bfdbfe !important;
}
.fc-day--today .fc-day-number {
display: inline;
background: none;
width: auto;
height: auto;
box-shadow: none;
border-radius: 0;
margin: 0;
color: var(--pf-primary);
font-weight: 900;
font-size: 1.1em;
}
/* =========================================================
MOBILE RESPONSIVE
========================================================= */
@media (max-width: 768px) {
.fc-smart-select {
font-size: 1rem;
padding: 6px 8px;
}
}
/* Mobile */
@media (max-width: 768px) {
.pf-family-calendar .pf-container {
padding: 0 !important;
}
@@ -927,6 +916,9 @@ td.col-laia-sub {
grid-template-columns: 1fr;
padding: 10px;
}
#selectionMenu,
#fc-month-selectionMenu,
.fc-selection-menu {
position: fixed !important;
top: auto !important;
@@ -939,7 +931,8 @@ td.col-laia-sub {
animation: slideUpSheet 0.3s forwards;
z-index: 10000 !important;
}
.fc-selection-menu::before {
#selectionMenu::before,
#fc-month-selectionMenu::before {
content: "";
display: block;
width: 40px;
@@ -955,3 +948,92 @@ td.col-laia-sub {
gap: 20px !important;
}
}
@keyframes slideUpSheet {
to {
transform: translateY(0);
}
}
/* ==========================================================================
AFFINAGE EXTRÊME DES COLONNES MOIS ET SEMAINE (Mise à jour)
========================================================================== */
/* 1. Colonne Mois (Texte vertical) : On réduit à 35px */
.col-month,
#planningTable th.col-sticky-mois,
#planningTable td.col-sticky-mois {
width: 15px !important;
min-width: 15px !important;
max-width: 15px !important;
padding: 2px !important;
}
/* 2. Colonne Semaine (Numéro court) : On réduit à 30px */
#planningTable th.col-sticky-sem,
#planningTable td.col-sticky-sem {
width: 10px !important;
min-width: 10px !important;
max-width: 10px !important;
padding: 2px !important;
font-size: 0.85rem !important;
}
/* 3. CORRECTION CRITIQUE : Le décalage "Sticky" de la colonne Semaine */
/* Puisque la colonne Mois fait 35px, la colonne Semaine doit démarrer à 35px (et non plus 54px) */
#planningTable tbody td.col-sticky-sem,
#planningTable thead tr th.col-sticky-sem {
left: 15px !important;
}
/* ==========================================================================
RÉGLAGES MILLIMÉTRÉS (FIX ALIGNEMENTS & STICKY)
========================================================================== */
/* 1. On force un alignement strict pour éviter que les bordures de "Av." et "Use" ne se désalignent */
#planningTable {
table-layout: fixed !important;
width: max-content !important;
min-width: 100% !important;
}
/* 2. Colonne Mois : Box-sizing inclut le padding pour un calcul mathématique exact */
.col-month,
#planningTable th.col-sticky-mois,
#planningTable td.col-sticky-mois {
box-sizing: border-box !important;
width: 25px !important; /* 15px + marges de sécurité */
min-width: 25px !important;
max-width: 25px !important;
padding: 2px !important;
overflow: hidden;
}
/* 3. Colonne Semaine : Le décalage (left) correspond EXACTEMENT à la largeur du Mois */
#planningTable th.col-sticky-sem,
#planningTable td.col-sticky-sem {
box-sizing: border-box !important;
left: 25px !important; /* Doit être rigoureusement égal au width du Mois ! */
width: 20px !important; /* 10px + marges */
min-width: 20px !important;
max-width: 20px !important;
padding: 2px !important;
font-size: 0.8rem !important;
overflow: hidden;
}
/* 4. Sécurité Anti-Trous pour les headers collants du haut (Empêche de voir à travers lors du scroll) */
#planningTable thead tr {
height: 32px !important;
}
#planningTable thead th {
height: 32px !important;
box-sizing: border-box !important;
}
#planningTable thead tr:nth-child(1) th {
top: 0 !important;
}
#planningTable thead tr:nth-child(2) th {
top: 32px !important;
}
#planningTable thead tr:nth-child(3) th {
top: 64px !important;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,194 @@
<?php
require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
require_login();
header('Content-Type: application/json');
$action = $_REQUEST['action'] ?? '';
try {
// ─── 1. RECUPÉRATION COMPLÈTE DES PARAMÈTRES (INITIALISATION) ───
if ($action === 'get_all') {
$stmtFoyer = $pdo->query("SELECT zone_scolaire, care_modes FROM pf_foyer_settings LIMIT 1");
$foyer = $stmtFoyer->fetch(PDO::FETCH_ASSOC) ?: ['zone_scolaire' => 'C', 'care_modes' => '[]'];
$stmtPeople = $pdo->query("SELECT id, name, role, care_modes, color FROM pf_people WHERE is_active = 1 ORDER BY role ASC, name ASC");
$people = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
$stmtLeaves = $pdo->query("SELECT person_id, leave_type, anniversary_date, method, allowance FROM pf_person_leave_meta");
$leavesRaw = $stmtLeaves->fetchAll(PDO::FETCH_ASSOC);
$leavesMap = [];
foreach ($leavesRaw as $leave) {
$leavesMap[$leave['person_id']][] = [
'type' => $leave['leave_type'],
'method' => $leave['method'] ?? 'FIXED',
'allowance' => $leave['allowance'] ?? 0,
'date' => $leave['anniversary_date']
];
}
$stmtSettings = $pdo->query("SELECT setting_key, setting_value FROM pf_settings WHERE module = 'calendar'");
$calendarSettingsRaw = $stmtSettings->fetchAll(PDO::FETCH_KEY_PAIR);
$calendarSettings = [
'calendar_default_view' => $calendarSettingsRaw['calendar_default_view'] ?? 'month',
'calendar_first_day' => $calendarSettingsRaw['calendar_first_day'] ?? '1',
'calendar_working_hours' => $calendarSettingsRaw['calendar_working_hours'] ?? '08:00-19:00'
];
// Catalogue des congés
$stmtLeaveTypes = $pdo->query("SELECT code, label, default_allowance, reset_month, allow_carry_over FROM pf_leave_types ORDER BY label ASC");
$leaveTypes = $stmtLeaveTypes->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'success' => true,
'data' => [
'foyer' => [
'zone_scolaire' => $foyer['zone_scolaire'],
'care_modes' => json_decode($foyer['care_modes'] ?? '[]', true)
],
'people' => $people,
'leaves' => $leavesMap,
'calendar_settings' => $calendarSettings,
'leave_types' => $leaveTypes
]
]);
exit;
}
// ─── 2. SAUVEGARDE DU FOYER (ONGLET 1) ───
if ($action === 'save_foyer') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$zone = trim($_POST['zone_scolaire'] ?? 'C');
$modesRaw = $_POST['care_modes'] ?? '[]';
$modesArray = json_encode(array_values(array_unique(array_filter(json_decode($modesRaw, true)))));
$stmt = $pdo->prepare("UPDATE pf_foyer_settings SET zone_scolaire = ?, care_modes = ? WHERE id = 1");
$stmt->execute([$zone, $modesArray]);
echo json_encode(['success' => true]);
exit;
}
// ─── 3. SAUVEGARDE DES MODES DE GARDE D'UN ENFANT ───
if ($action === 'save_child_care_modes') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$person_id = (int)($_POST['person_id'] ?? 0);
$care_modes = $_POST['care_modes'] ?? '[]';
if ($person_id > 0) {
$stmt = $pdo->prepare("UPDATE pf_people SET care_modes = ? WHERE id = ?");
$stmt->execute([$care_modes, $person_id]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'ID de l\'enfant manquant.']);
}
exit;
}
// ─── 4. GESTION DU CATALOGUE DE CONGÉS (FOYER) ───
if ($action === 'get_leave_types') {
$stmt = $pdo->query("SELECT * FROM pf_leave_types ORDER BY label ASC");
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
if ($action === 'save_leave_type') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$mode = $_POST['mode'] ?? 'add';
$code = strtoupper(trim($_POST['code'] ?? ''));
$label = trim($_POST['label'] ?? '');
if (empty($code) || empty($label)) throw new Exception("Le code et le label sont obligatoires.");
if ($mode === 'add') {
$stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label) VALUES (?, ?)");
$stmt->execute([$code, $label]);
} else {
$stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ? WHERE code = ?");
$stmt->execute([$label, $code]);
}
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_leave_type') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$code = strtoupper(trim($_POST['code'] ?? ''));
if (empty($code)) throw new Exception("Code du congé manquant.");
$pdo->beginTransaction();
// 1. On supprime d'abord ce congé chez tous les membres à qui on l'avait attribué
$stmtMeta = $pdo->prepare("DELETE FROM pf_person_leave_meta WHERE leave_type = ?");
$stmtMeta->execute([$code]);
// 2. Ensuite, on supprime le congé du catalogue global
$stmtCat = $pdo->prepare("DELETE FROM pf_leave_types WHERE code = ?");
$stmtCat->execute([$code]);
$pdo->commit();
echo json_encode(['success' => true]);
exit;
}
// ─── 5. GESTION DES CONGÉS INDIVIDUELS (MEMBRES) ───
if ($action === 'get_person_leaves') {
$personId = (int)($_GET['person_id'] ?? 0);
$stmt = $pdo->prepare("SELECT id, leave_type, allowance, method, anniversary_date FROM pf_person_leave_meta WHERE person_id = ? ORDER BY leave_type ASC");
$stmt->execute([$personId]);
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
if ($action === 'add_person_leave') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$personId = (int)($_POST['person_id'] ?? 0);
$type = strtoupper(trim($_POST['leave_type'] ?? ''));
$allowance = (float)($_POST['allowance'] ?? 0);
$resetMonth = (int)($_POST['reset_month'] ?? 1);
$method = in_array($_POST['method'] ?? '', ['FIXED', 'ACCUMULATED']) ? $_POST['method'] : 'FIXED';
if ($personId <= 0 || empty($type)) throw new Exception("Données invalides.");
// Vérification des doublons
$check = $pdo->prepare("SELECT id FROM pf_person_leave_meta WHERE person_id = ? AND leave_type = ?");
$check->execute([$personId, $type]);
if ($check->rowCount() > 0) throw new Exception("Ce congé est déjà attribué à cette personne.");
// On construit la date anniversaire avec le mois choisi par l'utilisateur
$anniversaryDate = "2000-" . str_pad((string)$resetMonth, 2, "0", STR_PAD_LEFT) . "-01";
$stmt = $pdo->prepare("INSERT INTO pf_person_leave_meta (person_id, leave_type, allowance, method, anniversary_date) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$personId, $type, $allowance, $method, $anniversaryDate]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete_person_leave') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
$id = (int)($_POST['id'] ?? 0);
$stmt = $pdo->prepare("DELETE FROM pf_person_leave_meta WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
throw new Exception("Action inconnue : " . htmlspecialchars($action));
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
}
@@ -51,3 +51,11 @@
[2026-05-06T20:38:24+02:00] RAW INPUT: [{"date":"2026-05-08","type":"AVIS","duration":1,"person":"Carole"}]
[2026-05-06T20: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-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';
try {
// 🔥 LE FIX : On interroge la NOUVELLE table des quotas individuels !
$stmt = $pdo->query("
SELECT person_id, leave_type, initial_balance, balance_year
FROM pf_leave_balances
SELECT
person_id,
leave_type AS type,
allowance,
method,
anniversary_date AS date
FROM pf_person_leave_meta
");
$balances = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['balances' => $balances]);
echo json_encode([
'status' => 'success',
'balances' => $balances
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode([
@@ -17,3 +26,4 @@ try {
'message' => $e->getMessage(),
]);
}
?>
@@ -8,77 +8,98 @@ $action = $input['action'] ?? '';
if (!$action) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Action manquante.']);
echo json_encode(['success' => false, 'status' => 'error', 'message' => 'Action manquante.']);
exit;
}
try {
// --- SUPPRESSION UNITAIRE ---
if ($action === 'delete') {
$eventId = (int)($input['event_id'] ?? 0);
if ($eventId <= 0) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'ID manquant.']);
echo json_encode(['success' => false, 'status' => 'error', 'message' => 'ID manquant.']);
exit;
}
$stmt = $pdo->prepare("DELETE FROM pf_events WHERE id = ?");
$stmt->execute([$eventId]);
echo json_encode(['status' => 'success']);
echo json_encode(['success' => true, 'status' => 'success']);
exit;
}
// --- MISE À JOUR UNITAIRE ---
if ($action === 'update') {
$eventId = (int)($input['event_id'] ?? 0);
$newType = $input['new_type'] ?? '';
if ($eventId <= 0 || !$newType) {
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;
}
$stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id = ?");
$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;
}
// --- 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') {
$dates = $input['dates'] ?? [];
$types = $input['types'] ?? [];
$dates = array_filter($dates, function($d) {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
});
$dates = array_filter($dates, function($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); });
if (empty($dates) || empty($types)) {
echo json_encode(['status' => 'success']);
echo json_encode(['success' => true, 'status' => 'success']);
exit;
}
// Création des placeholders IN (?,?,?)
$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)";
$stmt = $pdo->prepare($sql);
// Fusion des tableaux pour l'exécution
$stmt->execute(array_merge($dates, $types));
echo json_encode(['status' => 'success']);
echo json_encode(['success' => true, 'status' => 'success']);
exit;
}
// --- SUPPRESSION TOTALE SUR DES DATES ---
if ($action === 'bulk_delete_all') {
$dates = $input['dates'] ?? [];
$dates = array_filter($dates, function($d) {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
});
$dates = array_filter($dates, function($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); });
if (empty($dates) || empty($types)) {
echo json_encode(['status' => 'success']);
if (empty($dates)) {
echo json_encode(['success' => true, 'status' => 'success']);
exit;
}
@@ -87,15 +108,15 @@ try {
$stmt = $pdo->prepare($sql);
$stmt->execute($dates);
echo json_encode(['status' => 'success']);
echo json_encode(['success' => true, 'status' => 'success']);
exit;
}
// Action inconnue
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) {
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
// includes/api/save-events.php
// modules/family-calendar/includes/api/save-events.php
header('Content-Type: application/json');
require __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 = [];
require_once __DIR__ . '/../../../../includes/db.php';
try {
// 1. OPTIMISATION : On récupère toutes les personnes d'un coup (Mapping)
$stmtPeople = $pdo->query("SELECT id, name FROM pf_people");
$peopleMap = [];
while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) {
// On crée un tableau associatif : ['Carole' => 1, 'Alex' => 2, etc.]
$peopleMap[$row['name']] = $row['id'];
$rawInput = file_get_contents('php://input');
$eventsToSave = json_decode($rawInput, true);
if (empty($eventsToSave) || !is_array($eventsToSave)) {
throw new Exception("Aucune donnée d'événement reçue.");
}
$pdo->beginTransaction();
@@ -36,26 +17,36 @@ try {
VALUES (:event_date, :event_type, :person_id, :duration)";
$stmt = $pdo->prepare($sql);
foreach ($eventsToSave as $event) {
$person_id = null;
$inserted = [];
// 2. On vérifie simplement dans notre tableau (plus de requête SQL ici !)
if (!empty($event['person']) && isset($peopleMap[$event['person']])) {
$person_id = $peopleMap[$event['person']];
foreach ($eventsToSave as $event) {
if (empty($event['date']) || empty($event['type'])) {
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([
':event_date' => $event['date'],
':event_type' => $event['type'],
':event_type' => $safeType,
':person_id' => $person_id,
':duration' => $event['duration'] ?? 1.0,
':duration' => $duration,
]);
$inserted[] = [
'id' => $pdo->lastInsertId(),
'date' => $event['date'],
'type' => $event['type'],
'duration' => $event['duration'] ?? 1.0,
'type' => $safeType,
'duration' => $duration,
'person_id' => $person_id,
];
}
@@ -63,12 +54,20 @@ try {
$pdo->commit();
echo json_encode([
'success' => true,
'status' => 'success',
'inserted' => $inserted,
]);
} catch (Exception $e) {
$pdo->rollBack();
} catch (\Throwable $e) { // 🔥 LE FIX : \Throwable attrape AUSSI les fatals errors PDO
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
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()
]);
}
?>
+230 -9
View File
@@ -17,6 +17,24 @@ require_once dirname(__DIR__, 2) . '/includes/db.php';
$UPLOAD_DIR = '/uploads/garage/';
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'];
$action = $_GET['action'] ?? '';
@@ -38,6 +56,37 @@ function handleUpload(string $field, string $dir): ?string {
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 ─────────────────────────────────────────────────────────────────
if ($action === 'vehicles') {
if ($method === 'GET') {
@@ -47,21 +96,50 @@ if ($action === 'vehicles') {
$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();
$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);
}
$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());
}
if ($method === 'POST') {
$photo = handleUpload('photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
$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 (?,?,?,?,?,?,?,?,?,?,?,?,?)");
$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]);
$photo = handleUpload('photo', $UPLOAD_DIR);
$d = $_POST ?: gBody();
// 🔥 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()]);
}
if ($method === 'PUT') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
$int_fields = ['year','current_km']; $float_fields = ['purchase_price'];
$fields = ['name','brand','model','year','license_plate','vin','fuel_type','color','purchase_date','purchase_price','current_km','notes'];
$int_fields = ['year','current_km'];
$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);
$vals = array_map(function($f) use ($d, $int_fields, $float_fields) {
$v = $d[$f] ?? null;
@@ -76,11 +154,110 @@ if ($action === 'vehicles') {
}
if ($method === 'DELETE') {
$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']);
$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 ─────────────────────────────────────────────────────────────
if ($action === 'maintenances') {
$vid = $_GET['vehicle_id'] ?? null;
@@ -89,10 +266,13 @@ if ($action === 'maintenances') {
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->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);
}
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 = $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();
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->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();
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]);
@@ -126,6 +322,13 @@ if ($action === 'maintenances') {
}
if ($method === 'DELETE') {
$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]);
}
}
@@ -143,8 +346,26 @@ if ($action === 'parts') {
}
if ($method === 'POST') {
$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->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()]);
}
if ($method === 'PUT') {
@@ -206,4 +427,4 @@ if ($action === 'upload_part_photo') {
$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]);
}
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;
}
.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);
}
.table-wrap table tr:last-child td { border-bottom: none; }
@@ -268,7 +268,8 @@ textarea.form-control { resize: vertical; min-height: 80px; }
/* ─── TOASTS ─────────────────────────────────────────────────────────────────── */
.toast-container {
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 {
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 {
display: flex; align-items: center; gap: .75rem;
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;
}
.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-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 ─────────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.container { padding: 1rem; }
.vehicle-detail-header { flex-direction: column; }
.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; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.modal { max-height: 95vh; }
.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 .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 ══════════════════════════════════ */
@@ -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"] .item-name { color: var(--text-main); }
[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 -145
View File
@@ -6,7 +6,6 @@
margin: 0;
color: var(--text-main);
}
.cl-titlebar {
display: flex;
flex-wrap: wrap;
@@ -17,7 +16,6 @@
border-bottom: 1px solid var(--border-light);
padding-bottom: 15px;
}
.cl-view-switch {
display: flex;
padding: 4px;
@@ -25,7 +23,6 @@
border: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
}
.cl-view-btn {
padding: 6px 16px;
border-radius: 6px;
@@ -35,13 +32,12 @@
color: var(--text-muted);
transition: 0.2s;
}
.cl-view-btn.is-active {
background: #334155;
color: white;
background: var(--text-main);
color: var(--bg-panel);
}
/* === FILTRES STICKY === */
/* === FILTRES === */
.pf-filter-bar {
display: flex;
gap: 10px;
@@ -52,7 +48,6 @@
z-index: 50;
flex-wrap: wrap;
}
.pf-filter-label {
font-size: 0.85rem;
font-weight: 600;
@@ -62,40 +57,10 @@
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 === */
.pf-child-section {
margin-bottom: 30px;
background: white;
background: var(--bg-panel);
padding: 20px;
border-radius: 16px;
border: 1px solid var(--border-light);
@@ -120,21 +85,21 @@
overflow-x: auto;
padding-bottom: 12px;
margin-bottom: 15px;
border-bottom: 1px solid #f1f5f9;
scrollbar-width: none; /* Firefox */
border-bottom: 1px solid var(--border-light);
scrollbar-width: none;
}
.pf-child-totals-bar::-webkit-scrollbar {
display: none; /* Chrome/Safari */
display: none;
}
.pf-summary-pill {
white-space: nowrap;
background: #f8fafc;
background: var(--bg-page);
padding: 4px 12px;
border-radius: 50px;
font-size: 0.8rem;
font-weight: 700;
color: #475569;
border: 1px solid #cbd5e1;
color: var(--text-muted);
border: 1px solid var(--border-strong);
transition: opacity 0.2s;
}
@@ -145,9 +110,9 @@
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.pf-gift-card-compact {
background: white;
background: var(--bg-panel);
border-radius: 10px;
border: 1px solid #e2e8f0;
border: 1px solid var(--border-light);
padding: 12px;
position: relative;
transition: 0.2s;
@@ -156,12 +121,12 @@
min-height: 100px;
}
.pf-gift-card-compact:hover {
border-color: #cbd5e1;
border-color: var(--border-strong);
box-shadow: var(--shadow-md);
}
.pf-gift-title {
font-weight: 600;
color: #1e293b;
color: var(--text-main);
font-size: 0.95rem;
margin: 0 0 10px 0;
line-height: 1.3;
@@ -182,12 +147,6 @@
color: var(--success);
font-size: 1rem;
}
.pf-gift-badges-col {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.pf-pill-adult {
font-size: 0.7rem;
font-weight: 700;
@@ -196,14 +155,6 @@
padding: 2px 6px;
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 {
font-size: 0.7rem;
color: #ef4444;
@@ -223,7 +174,7 @@
margin: 0;
}
.pf-tricount-item {
background: white;
background: var(--bg-panel);
padding: 12px 15px;
border: 1px solid var(--border-light);
border-radius: 8px;
@@ -241,19 +192,158 @@
z-index: 2;
border-right: 2px solid var(--border-light);
}
/* Icônes des fêtes (Tió, Noël, etc.) plus petites et bien alignées */
.cl-occasion-icon {
width: 20px; /* Réduit pour être discret */
height: 20px;
object-fit: contain;
vertical-align: middle;
.gift-tricount-section {
border: 1px solid var(--border-light);
margin-top: 40px;
}
.gift-tricount-title {
margin-top: 0;
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 {
display: flex;
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) {
@@ -281,97 +371,159 @@
grid-template-columns: 1fr;
}
}
/* === COMPOSANT MULTI-SELECT VANILLA === */
.pf-multi-select {
position: relative;
display: inline-block;
/* === MODALE EDITION CADEAU === */
.gift-modal-custom-content {
max-width: 500px;
width: 95%;
}
.gift-modal-custom-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.gift-modal-custom-title {
margin: 0;
border: none;
padding: 0;
}
.gift-modal-close-btn {
background: none;
border: none;
font-size: 1.8rem;
cursor: pointer;
color: var(--text-muted);
line-height: 1;
}
.gift-form-group-spaced {
margin-bottom: 25px;
}
.gift-modal-custom-footer {
padding-top: 15px;
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid var(--border-light);
}
/* Le bouton déclencheur (réutilise le style des filtres) */
.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;
.gl-pane {
display: none;
animation: fadeIn 0.3s ease;
}
.gl-pane.active {
display: block;
}
.gl-pane-title {
margin-top: 0;
color: var(--text-main);
font-size: 1.1rem;
margin-bottom: 10px;
}
.gl-pane-desc {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 15px;
}
.gl-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--bg-panel);
border-radius: var(--radius);
border: 1px solid var(--border-light);
margin-bottom: 8px;
}
.gl-form-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: flex-end;
margin-top: 15px;
}
.gl-divider {
margin: 1.5rem 0;
border: 0;
border-bottom: 1px solid var(--border-light);
}
.gl-input-sm {
flex: 0 0 60px;
}
.gl-input-md {
flex: 1;
min-width: 120px;
}
.gift-loader {
text-align: center;
padding: 20px;
color: var(--text-muted);
font-style: italic;
}
.align-center {
align-items: center;
}
/* =========================================
COMPOSANTS MODALE & EDITION IN-PLACE
========================================= */
/* Sélecteur d'émojis horizontal */
.emoji-picker {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.emoji-option {
cursor: pointer;
box-shadow: var(--shadow-sm);
width: 38px;
height: 38px;
display: flex;
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;
}
.pf-ms-trigger:hover {
.emoji-option:hover {
background: var(--bg-page);
border-color: #cbd5e1;
}
.pf-ms-trigger.active {
.emoji-option.selected {
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 */
.pf-ms-dropdown {
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 {
/* Alignement des boutons d'ajout */
.modal-actions-right {
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) */
.pf-ms-option {
/* Édition In-Place sur les lignes de la liste */
.inline-edit-form {
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;
align-items: center;
background: var(--bg-subtle);
padding: 8px 12px;
border-radius: 8px;
width: 100%;
flex-wrap: wrap; /* Évite que ça dépasse sur mobile */
}
.pf-ms-option:hover {
background: #f8fafc;
}
.pf-ms-option input[type="checkbox"] {
.inline-edit-form .pf-input {
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
+1674 -362
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_login();
// INTERCEPTION AJAX : Sauvegarde du planning (Drag & Drop / Durée)
if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration'])) {
$itemId = (int)$_POST['item_id'];
// ---------------------------------------------------------------------------
// INTERCEPTIONS AJAX (Exécution ultra rapide sans rechargement lourd)
// ---------------------------------------------------------------------------
if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration', 'add_single_item', 'update_holiday_note'])) {
header('Content-Type: application/json');
session_write_close();
if ($_POST['action'] === 'update_item_datetime') {
$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]);
} else {
$duration = (int)$_POST['duration'];
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET duration = ? WHERE id = ?");
$stmt->execute([$duration, $itemId]);
try {
if ($_POST['action'] === 'update_holiday_note') {
$hId = (int)$_POST['holiday_id'];
$notes = $_POST['notes'] ?? '';
$stmt = $pdo->prepare("UPDATE pf_holidays SET notes = ? WHERE id = ?");
$stmt->execute([$notes, $hId]);
echo json_encode(['success' => true]);
exit;
}
if ($_POST['action'] === 'add_single_item') {
$holiday_id = (int)$_POST['holiday_id'];
$sort_order = (int)$_POST['sort_order'];
$stmt = $pdo->prepare("SELECT location_name, lat, lng, step_start_date, step_end_date, step_type FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ? LIMIT 1");
$stmt->execute([$holiday_id, $sort_order]);
$stepInfo = $stmt->fetch();
if ($stepInfo) {
$dur = isset($_POST['duration']) ? (int)$_POST['duration'] : 1;
$date = !empty($_POST['item_date']) ? $_POST['item_date'] : null;
$time = !empty($_POST['item_time']) ? $_POST['item_time'] : null;
// Récupération sécurisée du contexte
$context = !empty($_POST['expense_context']) ? $_POST['expense_context'] : (!empty($_POST['context']) ? $_POST['context'] : 'local');
$amount = (float)$_POST['amount'];
$name = $_POST['name'] ?? 'Trajet';
$category = $_POST['category'] ?? 'transport';
$pdo->beginTransaction();
// 🛡️ ANTI-DOUBLON ABSOLU
if ($context === 'transit') {
$stmtCheck = $pdo->prepare("SELECT id FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ? AND expense_context = 'transit'");
$stmtCheck->execute([$holiday_id, $sort_order]);
$existingId = $stmtCheck->fetchColumn();
if ($existingId) {
// Mise à jour de la ligne existante
$stmtUp = $pdo->prepare("UPDATE pf_holidays_items SET item_date = ?, item_time = ?, amount = ?, duration = ?, name = ? WHERE id = ?");
$stmtUp->execute([$date, $time, $amount, $dur, $name, $existingId]);
$pdo->commit();
echo json_encode(['success' => true, 'id' => $existingId]);
exit;
}
}
// Insertion si aucun doublon n'existe
$ins = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng, sort_order, step_start_date, step_end_date, step_type, expense_context, duration, item_date, item_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$ins->execute([
$holiday_id, $category, $name, $amount, 0,
$stepInfo['location_name'], $stepInfo['lat'], $stepInfo['lng'],
$sort_order, $stepInfo['step_start_date'], $stepInfo['step_end_date'],
$stepInfo['step_type'], $context, $dur, $date, $time
]);
$newId = $pdo->lastInsertId();
$pdo->commit();
echo json_encode(['success' => true, 'id' => $newId]);
exit;
}
echo json_encode(['success' => false, 'error' => 'Etape introuvable']);
exit;
}
// Drag & Drop planning : Date et Heure
if ($_POST['action'] === 'update_item_datetime') {
$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'];
$location_name = trim($_POST['location_name']);
$lat = (float)$_POST['lat'];
$lng = (float)$_POST['lng'];
$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') {
$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);
@@ -40,26 +125,34 @@ if ($holiday_id > 0 && !empty($location_name)) {
try {
$pdo->beginTransaction();
// 2. DÉTERMINER L'ORDRE (Identifiant de l'étape)
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]);
$target_order = $old_sort_order;
} else {
// Ajout : on place à la fin (Max + 1)
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
$stmtMax->execute([$holiday_id]);
$max = $stmtMax->fetchColumn();
$target_order = ($max !== null) ? (int)$max + 1 : 0;
// NOUVEAU : Logique d'intercalage d'étape
$insert_after = $_POST['insert_after'] ?? 'end';
if ($insert_after === 'end') {
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
$stmtMax->execute([$holiday_id]);
$max = $stmtMax->fetchColumn();
$target_order = ($max !== null) ? (int)$max + 1 : 0;
} else {
$target_order = (int)$insert_after + 1;
// On décale toutes les étapes suivantes vers le bas
$pdo->prepare("UPDATE pf_holidays_items SET sort_order = sort_order + 1 WHERE holiday_id = ? AND sort_order >= ?")->execute([$holiday_id, $target_order]);
}
}
// Récupération des dates de l'étape globale
$step_start = !empty($_POST['step_start_date']) ? $_POST['step_start_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)
$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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
if ($step_type === 'origin' || $step_type === 'destination') {
$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;
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;
$time = !empty($_POST['items']['time'][$i]) ? $_POST['items']['time'][$i] : null;
$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
$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]);
// Récupération des coordonnées spécifiques à l'activité (ou fallback sur l'étape)
$itemLat = !empty($_POST['items']['lat'][$i]) ? (float)$_POST['items']['lat'][$i] : $lat;
$itemLng = !empty($_POST['items']['lng'][$i]) ? (float)$_POST['items']['lng'][$i] : $lng;
$stmt->execute([$holiday_id, $cat, $name, $amount, $paid, $location_name, $itemLat, $itemLng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $step_type, $context]);
$validItemsCount++;
}
}
}
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') {
$stmtFav = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'holiday_favorites'");
$favs = json_decode($stmtFav->fetchColumn() ?: '[]', true);
@@ -100,7 +200,10 @@ if ($holiday_id > 0 && !empty($location_name)) {
}
$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);
+43 -45
View File
@@ -1,10 +1,10 @@
<?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/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') {
$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;
$extra = !empty($_POST['budget_extra']) ? $_POST['budget_extra'] : 0;
$notes = $_POST['notes'];
// 🔥 NOUVEAU : On récupère le véhicule optionnel
$vehicle_id = !empty($_POST['vehicle_id']) ? (int)$_POST['vehicle_id'] : null;
try {
$pdo->beginTransaction();
// 1. Détection d'un changement de nom pour refaire l'appel API
$image_url = null;
$old_title = null;
if ($id) {
// UPDATE
$sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=? WHERE id=?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $id]);
} else {
// 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();
$stmtOld = $pdo->prepare("SELECT title, image_url FROM pf_holidays WHERE id = ?");
$stmtOld->execute([$id]);
if ($row = $stmtOld->fetch()) {
$old_title = $row['title'];
$image_url = $row['image_url'];
}
}
// GESTION INTELLIGENTE DES ITEMS
if (!empty($_POST['items']['name'])) {
$count = count($_POST['items']['name']);
// On prépare une requête qui met à jour si l'ID existe, sinon insère
$stmtItem = $pdo->prepare("
INSERT INTO pf_holidays_items (id, holiday_id, category, name, amount, is_paid, location_name)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
category = VALUES(category),
name = VALUES(name),
amount = VALUES(amount),
is_paid = VALUES(is_paid)
");
// 2. Appel à Pixabay si nouveau voyage ou si le titre a changé
if (!$id || strcasecmp($old_title ?? '', $title) !== 0) {
$apiKey = '56931941-a86fbea4e14b88712cc1e9ed9';
$query = urlencode($title);
// On force des photos horizontales HD
$url = "https://pixabay.com/api/?key={$apiKey}&q={$query}&image_type=photo&orientation=horizontal&min_width=1280&per_page=3";
$keepIds = [];
for ($i = 0; $i < $count; $i++) {
$itemId = !empty($_POST['items']['id'][$i]) ? (int)$_POST['items']['id'][$i] : null;
$cat = $_POST['items']['cat'][$i] ?? 'activity';
$name = trim($_POST['items']['name'][$i] ?? '');
$amount = floatval($_POST['items']['amount'][$i] ?? 0);
$paid = (int)($_POST['items']['paid'][$i] ?? 0);
$loc = !empty($_POST['items']['location'][$i]) ? $_POST['items']['location'][$i] : null;
if (!empty($name)) {
$stmtItem->execute([$itemId, $id, $cat, $name, $amount, $paid, $loc]);
$keepIds[] = $itemId ?: $pdo->lastInsertId();
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 3); // Timeout de 3s max pour ne pas bloquer l'UI
$response = curl_exec($ch);
curl_close($ch);
if ($response) {
$data = json_decode($response, true);
if (!empty($data['hits'][0]['largeImageURL'])) {
$image_url = $data['hits'][0]['largeImageURL'];
}
}
}
// Nettoyage : On supprime les items qui ont été retirés de la modale
// (Attention : uniquement ceux du voyage actuel qui ne sont plus dans la liste envoyée)
if (!empty($keepIds)) {
$placeholders = implode(',', array_fill(0, count($keepIds), '?'));
$sqlDel = "DELETE FROM pf_holidays_items WHERE holiday_id = ? AND id NOT IN ($placeholders)";
$pdo->prepare($sqlDel)->execute(array_merge([$id], $keepIds));
}
// 3. Sauvegarde en Base
if ($id) {
// UPDATE
$sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=?, vehicle_id=?, image_url=? WHERE id=?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $vehicle_id, $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();
@@ -84,6 +83,5 @@ try {
die("Erreur base de données : " . $e->getMessage());
}
// Redirection vers la page principale
header("Location: /holidays.php");
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
$stmt = $pdo->prepare("
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,
(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
FROM pf_holidays h WHERE h.id = ?
(SELECT COALESCE(SUM(amount), 0) FROM pf_savings WHERE holiday_id = h.id) as total_saved,
(SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND (expense_context = 'transit' OR (category = 'transport' AND (name LIKE '%Essence%' OR name LIKE '%Carburant%')))) as total_fuel,
(SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND category = 'transport' AND (name LIKE '%Péage%' OR name LIKE '%Peage%')) as total_tolls
FROM pf_holidays h
LEFT JOIN pf_vehicles v ON h.vehicle_id = v.id
WHERE h.id = ?
");
$stmt->execute([$id]);
$holiday = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -42,7 +49,7 @@ foreach ($items as $it) {
'sort_order' => $it['sort_order'],
'step_start_date' => $it['step_start_date'],
'step_end_date' => $it['step_end_date'],
'is_return' => (int)$it['is_return'],
'step_type' => $it['step_type'] ?? 'stop',
'total_amount' => 0,
'items' => []
];
@@ -59,8 +66,9 @@ $mapPoints = array_values($steps);
// Affichage de la date
$dateDisplay = htmlspecialchars($holiday['period_hint'] ?? '');
if (empty($dateDisplay) && $holiday['start_date']) {
$dateDisplay = date('d/m/Y', strtotime($holiday['start_date']));
if ($holiday['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($holiday['end_date']));
// 💡 Format Jour/Mois uniquement
$dateDisplay = date('d/m', strtotime($holiday['start_date']));
if ($holiday['end_date']) $dateDisplay .= ' → ' . date('d/m', strtotime($holiday['end_date']));
}
$cost = (float)$holiday['total_cost'];
@@ -69,211 +77,259 @@ $saved = (float)$holiday['total_saved'];
$leftToPay = max(0, $cost - $paid);
$pctPaid = $cost > 0 ? min(100, ($paid / $cost) * 100) : 0;
$pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
// Image de fond (API Pixabay ou fallback par défaut)
$bgImage = !empty($holiday['image_url']) ? htmlspecialchars($holiday['image_url']) : '/modules/home/assets/img/background.jpg';
?>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
.mobile-only { display: none !important; }
@media (max-width: 768px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
</style>
<div class="pf-holidays-detail">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; flex-wrap: wrap; gap: 15px;">
<div style="display: flex; flex-direction: column; gap: 12px;">
<a href="?tab=list" class="pf-btn btn-secondary pf-btn-small" style="width: fit-content; text-decoration: none;"><?= tr('btn_back') ?></a>
<div style="display: flex; align-items: center; gap: 15px;">
<h1 style="margin: 0; font-size: 1.8rem;"><?= htmlspecialchars($holiday['title']) ?></h1>
<span class="hol-badge-status"><?= tr('hdl_status_' . $holiday['status']) ?></span>
</div>
</div>
<!-- Scripts JSON invisibles -->
<script id="holidayDataJson" type="application/json">
<?= json_encode(['main' => $holiday, 'items' => $generalItems], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>
</script>
<div>
<script id="holidayDataJson" type="application/json">
<?= json_encode(['main' => $holiday, 'items' => $generalItems], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>
</script>
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="editHoliday(JSON.parse(document.getElementById('holidayDataJson').textContent))">
<?= tr('btn_edit_bases') ?>
</button>
</div>
</div>
<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>
<!-- 1. HERO HEADER IMMERSIF -->
<div class="hol-hero-banner">
<img src="<?= $bgImage ?>" class="hol-hero-bg-img" alt="">
<div class="hol-hero-overlay"></div>
<div class="hol-hero-content">
<div class="hol-hero-top">
<a href="?tab=list" class="hol-hero-back-link"> <?= tr('btn_back') ?></a>
<span class="hol-hero-status-badge"><?= tr('hdl_status_' . $holiday['status']) ?></span>
</div>
<div class="hol-panel-body">
<?php if (empty($steps)): ?>
<p style="color:var(--text-muted); font-style:italic; text-align:center; margin-top:40px;"><?= tr('hdl_no_steps') ?></p>
<?php else: ?>
<h1 class="hol-hero-title"><?= htmlspecialchars($holiday['title']) ?></h1>
<div class="hol-hero-date-badge">
🗓️ <?= $dateDisplay ?>
</div>
<?php if (!empty($generalItems) || $holiday['budget_food'] > 0 || $holiday['budget_extra'] > 0): ?>
<div class="hol-checkpoint" style="border-left-color: #64748b; background: #f8fafc; margin-bottom: 20px;">
<div class="hol-cp-header" style="background: transparent;">
<div class="hol-cp-info-group">
<span style="font-size:1.4rem;">🌍</span>
<div class="hol-cp-title"><?= tr('hdl_general_costs') ?></div>
</div>
<?php
$generalTotal = $holiday['budget_food'] + $holiday['budget_extra'];
foreach ($generalItems as $gi) { $generalTotal += $gi['amount']; }
?>
<div class="hol-cp-actions-group">
<div class="hol-cp-total"><?= number_format($generalTotal, 2, ',', ' ') ?></div>
<button onclick='editHoliday(JSON.parse(document.getElementById("holidayDataJson").textContent))' class="btn-icon-small" title="<?= tr('btn_edit') ?>">⚙️</button>
</div>
</div>
<!-- LES PILLS (Indicateurs Route & Quotidien) -->
<div class="hol-hero-pills">
<!-- PILL ROUTE (Masqué par défaut, géré par OSRM) -->
<span id="block_total_distance" class="hol-pill-block hol-hidden">
<span class="hol-pill">
🛣️ <span id="global_total_distance">0</span> km (⏱️ <span id="global_total_duration">0h00</span>)
</span>
</span>
<!-- PILL PÉAGES -->
<span class="hol-pill">
💳 <?= number_format($holiday['total_tolls'], 0) ?>
</span>
<div class="hol-cp-body">
<?php if ($holiday['budget_food'] > 0): ?>
<div class="hol-expense-wrapper"><div class="hol-expense-main">
<span class="hol-expense-name" style="color:#64748b;">🍔 <?= tr('hdl_food_bev') ?></span>
<span><strong class="hol-expense-amount"><?= number_format($holiday['budget_food'], 2, ',', ' ') ?> €</strong><span class="status-pending">⏳</span></span>
</div></div>
<?php endif; ?>
<?php if ($holiday['budget_extra'] > 0): ?>
<div class="hol-expense-wrapper"><div class="hol-expense-main">
<span class="hol-expense-name" style="color:#64748b;">🎁 <?= tr('hdl_extras') ?></span>
<span><strong class="hol-expense-amount"><?= number_format($holiday['budget_extra'], 2, ',', ' ') ?> €</strong><span class="status-pending">⏳</span></span>
</div></div>
<?php endif; ?>
<?php foreach ($generalItems as $it):
$icon = match($it['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
?>
<div class="hol-expense-wrapper"><div class="hol-expense-main">
<span class="hol-expense-name"><?= $icon ?> <?= htmlspecialchars($it['name']) ?></span>
<span>
<strong class="hol-expense-amount"><?= number_format($it['amount'], 2, ',', ' ') ?> €</strong>
<span class="<?= $it['is_paid'] ? 'status-paid' : 'status-pending' ?>"><?= $it['is_paid'] ? '✓' : '⏳' ?></span>
</span>
</div></div>
<?php endforeach; ?>
</div>
</div>
<!-- PILL ESSENCE (Avec Edit + Oeil) -->
<span class="hol-pill">
<span id="global_fuel_cost"><?= number_format($holiday['total_fuel'], 0) ?></span> €
<span onclick="updateFuelPrice()" class="hol-pill-action" title="Modifier le prix de l'essence">(<span id="display_fuel_price">1.85</span>/L) ✏️</span>
<?php if ($holiday['total_fuel'] > 0 || $holiday['total_tolls'] > 0): ?>
<span onclick="openTransitModal()" class="hol-pill-action" title="Voir le détail des trajets">👁️</span>
<?php endif; ?>
</span>
<?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-cp-header">
<div class="hol-cp-info-group">
<span class="desktop-only" style="color:#94a3b8; font-size:1.1rem; cursor:grab; user-select:none;"></span>
<div class="mobile-only" style="flex-direction:column; gap:4px; margin-right:8px;">
<button type="button" onclick="moveStepMobile(this, -1)" class="btn-icon-small" style="width:24px!important; height:24px!important; font-size:0.6rem;"></button>
<button type="button" onclick="moveStepMobile(this, 1)" class="btn-icon-small" style="width:24px!important; height:24px!important; font-size:0.6rem;"></button>
<!-- PILL BURGER (Food) -->
<span class="hol-pill">
🍔 <?= number_format($holiday['budget_food'], 0) ?>
</span>
<!-- PILL CADEAU (Extras) -->
<span class="hol-pill">
🎁 <?= number_format($holiday['budget_extra'], 0) ?>
</span>
</div>
<!-- FINANCES GLOBALES -->
<div class="hol-hero-finances">
<div class="hol-finance-block">
<div class="hol-finance-label"><?= tr('hdl_total_budget') ?></div>
<div class="hol-finance-value"><?= number_format($cost, 0, ',', ' ') ?> €</div>
</div>
<div class="hol-finance-block hol-finance-right">
<div class="hol-finance-label"><?= tr('hdl_left_to_pay') ?></div>
<div class="hol-finance-value hol-finance-value-due"><?= number_format($leftToPay, 0, ',', ' ') ?> €</div>
</div>
</div>
<!-- Barre de progression -->
<div class="hol-progress-bar">
<div class="hol-progress-paid" style="width: <?= $pctPaid ?>%;"></div>
<div class="hol-progress-saved" style="width: <?= $pctSaved ?>%;"></div>
</div>
<div class="hol-hero-progress-labels">
<span class="hol-label-paid-text"> <?= tr('hdl_paid') ?> : <?= number_format($paid, 0, ',', ' ') ?> €</span>
<span class="hol-label-saved-text">💼 <?= tr('hdl_saved') ?> : <?= number_format($saved, 0, ',', ' ') ?> €</span>
</div>
</div>
</div>
<!-- 2. BARRE D'ACTIONS RAPIDES -->
<div class="hol-quick-actions-bar">
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="toggleTripMap()">
🗺️ Carte
</button>
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="openGlobalPlanningModal()">
📅 Planning
</button>
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="generateTravelBook()">
📖 PDF
</button>
<button type="button" class="pf-btn btn-secondary pf-btn-small hol-quick-action-btn" onclick="editHoliday(JSON.parse(document.getElementById('holidayDataJson').textContent))">
⚙️ Modifier
</button>
</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 class="hol-cp-title" onclick="panMapTo(<?= $step['lat'] ?>, <?= $step['lng'] ?>)" title="<?= htmlspecialchars($step['location_name']) ?>">
📍 <?= htmlspecialchars($step['location_name']) ?>
<?php if (!empty($step['is_return'])): ?>
<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>
<?php if (!empty($it['notes'])): ?>
<div class="hol-expense-note">
<?= htmlspecialchars($it['notes']) ?>
</div>
<?php endif; ?>
</div>
</div>
<div class="hol-cp-actions-group">
<div class="hol-cp-total"><?= number_format($step['total_amount'], 2, ',', ' ') ?> €</div>
<button onclick='openPlanningModal(<?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="<?= tr('hdl_view_planning') ?>">📅</button>
<button onclick='openCheckpointModal("edit", <?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="<?= tr('btn_edit') ?>">✏️</button>
</div>
</div>
<div 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>
<?php endforeach; ?>
<?php if ($visibleItemsCount === 0): ?>
<div class="hol-empty-step">📍 <?= tr('hdl_passing_point') ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?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>
<?php if (!empty($holiday['notes'])): ?>
<div class="hol-summary-card" style="margin-top: 24px; padding: 25px; border-left: 5px solid #f59e0b;">
<h3 style="margin: 0 0 15px 0; font-size: 1.2rem; color: #0f172a; display: flex; align-items: center; gap: 8px;">
📝 <?= tr('hdl_label_notes') ?>
</h3>
<div style="font-size: 0.95rem; color: #334155; white-space: pre-wrap; line-height: 1.6;">
<?= htmlspecialchars($holiday['notes']) ?>
</div>
</div>
<?php endif; ?>
</div>
<div id="checkpointModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 600px;">
<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>
</div>
<!-- LES ONGLETS SONT ICI -->
<div style="display: flex; gap: 15px; border-bottom: 1px solid var(--border-light); margin-bottom: 20px;">
<button type="button" id="tabBtnInfo" onclick="switchCpTab('info')" style="background: none; border: none; font-weight: bold; font-size: 1rem; color: var(--primary); border-bottom: 2px solid var(--primary); padding-bottom: 8px; cursor: pointer; flex: 1;">📍 Infos de l'étape</button>
<button type="button" id="tabBtnProg" onclick="switchCpTab('prog')" style="background: none; border: none; font-weight: bold; font-size: 1rem; color: var(--text-muted); border-bottom: 2px solid transparent; padding-bottom: 8px; cursor: pointer; flex: 1;">🎟️ Programme & Frais</button>
</div>
<div id="cpSearchBlock" style="margin-bottom:20px;">
<?php if (!empty($favorites)): ?>
<div style="margin-bottom:15px; display:flex; gap:8px; flex-wrap:wrap;">
@@ -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>
<!-- FORMULAIRE (Le contenu apparaîtra ici) -->
<form action="/modules/holidays/includes/api/save_checkpoint.php" method="POST" id="formCheckpoint" style="display:none; border-top:1px solid #e2e8f0; padding-top:20px;">
<input type="hidden" name="holiday_id" value="<?= $id ?>">
<input type="hidden" name="old_location_name" id="cp_old_name">
@@ -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="old_sort_order" id="cp_old_sort_order">
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('hdl_label_step_name') ?></label>
<input type="text" name="location_name" id="cp_name" class="pf-input" style="font-weight:bold; color:var(--primary);" required>
</div>
<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">
<!-- CONTENU DE L'ONGLET INFO -->
<div id="cpTabInfo">
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('hdl_label_step_name') ?></label>
<input type="text" name="location_name" id="cp_name" class="pf-input" style="font-weight:bold; color:var(--primary);" required>
</div>
<div class="form-group" style="flex:1;">
<label class="pf-label"><?= tr('hdl_label_departure') ?></label>
<input type="date" name="step_end_date" id="cp_end_date" class="pf-input">
<div class="form-group" style="margin-bottom:15px; background:#fff7ed; padding:10px; border-radius:8px; border:1px solid #ffedd5;">
<label class="pf-label" style="color:#ea580c;">📍 Type d'étape</label>
<select name="step_type" id="cp_step_type" class="pf-input" onchange="toggleStepDates(this.value)">
<option value="origin">DÉPART (Point de départ du voyage)</option>
<option value="stop">SÉJOUR (Étape classique avec arrivée et départ)</option>
<option value="destination">ARRIVÉE FINALE (Fin du voyage)</option>
</select>
</div>
<div style="display:flex; gap:15px; margin-bottom:15px; background:#f8fafc; padding:12px; border-radius:8px;">
<div 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 style="margin-bottom: 15px;">
<label style="display:flex; align-items:center; cursor:pointer; color:#ea580c; font-weight:600;">
<input type="checkbox" name="is_return" id="cp_is_return" value="1" style="margin-right:8px;">
🏁 <?= tr('hdl_return') ?>
</label>
</div>
<!-- CONTENU DE L'ONGLET PROGRAMME & FRAIS -->
<div id="cpTabProg" class="hol-hidden" style="display: none;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<label class="pf-label" style="margin:0;"><?= tr('hdl_planned_expenses') ?></label>
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="addCpExpenseLine()"><?= tr('hdl_btn_add_expense') ?></button>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<label class="pf-label" style="margin:0;"><?= tr('hdl_planned_expenses') ?></label>
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="addCpExpenseLine()"><?= tr('hdl_btn_add_expense') ?></button>
<div id="cpExpensesContainer" style="margin-bottom:15px;"></div>
</div>
<div id="cpExpensesContainer" style="margin-bottom:15px;"></div>
<div style="margin-bottom: 20px;">
<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;">
@@ -354,67 +433,374 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
</div>
<div id="planningModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 550px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 id="planningModalTitle" style="margin:0; color:var(--primary);">📅 <?= tr('hdl_planning_title') ?></h3>
<div class="pf-modal-content hol-modal-planning">
<div class="hol-modal-header">
<h3 id="planningModalTitle" class="hol-modal-title">📅 Planning Global</h3>
<button type="button" onclick="closePlanningModal()" class="pf-modal-close">&times;</button>
</div>
<div id="planningContainer" style="width: 100%;"></div>
<!-- Conteneur Injecté en JS -->
<div id="planningContainer" class="hol-planning-container"></div>
<div class="modal-footer">
<button type="button" onclick="closePlanningModal()" class="pf-btn btn-secondary"><?= tr('btn_close') ?></button>
</div>
</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'; ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<?php include __DIR__ . '/pdf_template.php'; ?>
<script>
// --- 1. SÉCURISATION TRADUCTIONS ET VARIABLES ---
window.MAP_POINTS = <?= json_encode($mapPoints ?? []) ?>;
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
// --- 1. SÉCURISATION TRADUCTIONS ET VARIABLES ---
window.MAP_POINTS = <?= json_encode($mapPoints ?? []) ?>;
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
'hdl_js_search_loading': "<?= tr('hdl_js_search_loading') ?>",
'hdl_js_no_result': "<?= tr('hdl_js_no_result') ?>",
'hdl_js_confirm_del_trip': "<?= tr('hdl_js_confirm_del_trip') ?>",
'hdl_js_confirm_del_step': "<?= tr('hdl_js_confirm_del_step') ?>",
'hdl_js_step_label': "<?= tr('hdl_js_step_label') ?>",
'hdl_js_ph_expense_name': "<?= tr('hdl_js_ph_expense_name') ?>",
'hdl_js_delete_line': "<?= tr('btn_delete') ?>",
'hdl_planning_title': "<?= tr('hdl_planning_title') ?>",
'hdl_to_place': "<?= tr('hdl_to_place') ?>",
'hdl_js_missing_dates_title': "<?= tr('hdl_js_missing_dates_title') ?>",
'hdl_js_missing_dates_msg': "<?= tr('hdl_js_missing_dates_msg') ?>",
'hdl_modal_title': "<?= tr('hdl_modal_title') ?>",
'hdl_js_edit_step': "<?= tr('hdl_js_edit_step') ?>",
'hdl_ph_notes': "<?= tr('hdl_ph_notes') ?>",
'hdl_btn_add_step': "<?= tr('hdl_btn_add_step') ?>",
'hdl_quick_edit_title': "<?= tr('hdl_quick_edit_title') ?>",
'hdl_paid': "<?= tr('hdl_paid') ?>",
window.I18N = {
...(window.I18N || {}),
'hdl_js_search_loading': <?= json_encode(tr('hdl_js_search_loading')) ?>,
'hdl_js_no_result': <?= json_encode(tr('hdl_js_no_result')) ?>,
'hdl_js_confirm_del_trip': <?= json_encode(tr('hdl_js_confirm_del_trip')) ?>,
'hdl_js_confirm_del_step': <?= json_encode(tr('hdl_js_confirm_del_step')) ?>,
'hdl_js_step_label': <?= json_encode(tr('hdl_js_step_label')) ?>,
'hdl_js_ph_expense_name': <?= json_encode(tr('hdl_js_ph_expense_name')) ?>,
'hdl_js_delete_line': <?= json_encode(tr('btn_delete')) ?>,
'hdl_planning_title': <?= json_encode(tr('hdl_planning_title')) ?>,
'hdl_to_place': <?= json_encode(tr('hdl_to_place')) ?>,
'hdl_js_missing_dates_title': <?= json_encode(tr('hdl_js_missing_dates_title')) ?>,
'hdl_js_missing_dates_msg': <?= json_encode(tr('hdl_js_missing_dates_msg')) ?>,
'hdl_modal_title': <?= json_encode(tr('hdl_modal_title')) ?>,
'hdl_js_edit_step': <?= json_encode(tr('hdl_js_edit_step')) ?>,
'hdl_ph_notes': <?= json_encode(tr('hdl_ph_notes')) ?>,
'hdl_btn_add_step': <?= json_encode(tr('hdl_btn_add_step')) ?>,
'hdl_quick_edit_title': <?= json_encode(tr('hdl_quick_edit_title')) ?>,
'hdl_paid': <?= json_encode(tr('hdl_paid')) ?>,
'weather_sunny': <?= json_encode(tr('weather_sunny')) ?>,
'weather_cloudy': <?= json_encode(tr('weather_cloudy')) ?>,
'weather_rainy': <?= json_encode(tr('weather_rainy')) ?>,
'weather_snowy': <?= json_encode(tr('weather_snowy')) ?>,
'weather_forecast': <?= json_encode(tr('weather_forecast')) ?>,
'weather_historical': <?= json_encode(tr('weather_historical')) ?>
};
const savedFuelPrice = localStorage.getItem('holidays_fuel_price') || 1.85;
window.FUEL_PRICE = parseFloat(savedFuelPrice);
// --- NOUVELLES CLÉS MÉTÉO ICI ---
'weather_sunny': "<?= tr('weather_sunny') ?>",
'weather_cloudy': "<?= tr('weather_cloudy') ?>",
'weather_rainy': "<?= tr('weather_rainy') ?>",
'weather_snowy': "<?= tr('weather_snowy') ?>",
'weather_forecast': "<?= tr('weather_forecast') ?>",
'weather_historical': "<?= tr('weather_historical') ?>"
};
const displayFuelEl = document.getElementById('display_fuel_price');
if (displayFuelEl) displayFuelEl.innerText = window.FUEL_PRICE.toFixed(2);
// Fallback de sécurité pour s'assurer que les modales peuvent toujours se fermer
window.closeCheckpointModal = window.closeCheckpointModal || function() {
const modal = document.getElementById('checkpointModal');
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll');
};
window.VEHICLE_CONSUMPTION = <?= !empty($holiday['vehicle_consumption']) ? (float)$holiday['vehicle_consumption'] : 7 ?>;
window.GLOBAL_RETURN_STEP_ID = <?= $holiday['return_step_id'] !== null ? $holiday['return_step_id'] : 'null' ?>;
window.closePlanningModal = window.closePlanningModal || function() {
const modal = document.getElementById('planningModal');
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll');
};
// --- 2. SURCHARGE SÉCURISÉE DES FONCTIONS JS (APRÈS CHARGEMENT DE HOLIDAYS.JS) ---
document.addEventListener('DOMContentLoaded', function() {
// 1. Correction du conflit d'onglet
window.switchCpTab = function(tabId) {
const btnInfo = document.getElementById('tabBtnInfo');
const btnProg = document.getElementById('tabBtnProg');
const tabInfo = document.getElementById('cpTabInfo');
const tabProg = document.getElementById('cpTabProg');
if (tabId === 'info') {
if (btnInfo) { btnInfo.style.borderBottomColor = "var(--primary)"; btnInfo.style.color = "var(--primary)"; }
if (btnProg) { btnProg.style.borderBottomColor = "transparent"; btnProg.style.color = "var(--text-muted)"; }
if (tabInfo) { tabInfo.style.display = "block"; tabInfo.classList.remove("hol-hidden"); }
if (tabProg) { tabProg.style.display = "none"; tabProg.classList.add("hol-hidden"); }
} else {
if (btnProg) { btnProg.style.borderBottomColor = "var(--primary)"; btnProg.style.color = "var(--primary)"; }
if (btnInfo) { btnInfo.style.borderBottomColor = "transparent"; btnInfo.style.color = "var(--text-muted)"; }
if (tabProg) { tabProg.style.display = "block"; tabProg.classList.remove("hol-hidden"); }
if (tabInfo) { tabInfo.style.display = "none"; tabInfo.classList.add("hol-hidden"); }
}
};
// 2. Correction modale : ajout de l'injection lat/lng aux items
window.openCheckpointModal = function(mode, data = null) {
const searchBlock = document.getElementById("cpSearchBlock");
const formBlock = document.getElementById("formCheckpoint");
const container = document.getElementById("cpExpensesContainer");
const btnDel = document.getElementById("btnDeleteCp");
const modalTitle = document.getElementById("cpModalTitle");
if(container) container.innerHTML = "";
if (document.getElementById("cp_start_date")) document.getElementById("cp_start_date").value = "";
if (document.getElementById("cp_end_date")) document.getElementById("cp_end_date").value = "";
if (document.getElementById("searchPlaceInput")) document.getElementById("searchPlaceInput").value = "";
if (document.getElementById("searchResults")) document.getElementById("searchResults").innerHTML = "";
window.switchCpTab('info');
if (mode === "add") {
if (modalTitle) modalTitle.innerText = "📍 " + (window.I18N['hdl_btn_add_step'] || "Ajouter une étape");
if (searchBlock) searchBlock.style.display = "block";
if (formBlock) formBlock.style.display = "block";
if (btnDel) btnDel.style.display = "none";
if (document.getElementById("cp_old_sort_order")) document.getElementById("cp_old_sort_order").value = "";
if (document.getElementById("cp_name")) document.getElementById("cp_name").value = "";
if (document.getElementById("cp_step_type")) {
document.getElementById("cp_step_type").value = "stop";
if (typeof toggleStepDates === 'function') toggleStepDates("stop");
}
if (document.getElementById("cp_set_as_return")) document.getElementById("cp_set_as_return").checked = false;
if (typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
} else if (mode === "edit" && data) {
if (modalTitle) modalTitle.innerText = "✏️ " + (window.I18N['hdl_js_edit_step'] || "Modifier l'étape");
if (searchBlock) searchBlock.style.display = "none";
if (formBlock) formBlock.style.display = "block";
if (btnDel) btnDel.style.display = "block";
if (document.getElementById("cp_lat")) document.getElementById("cp_lat").value = data.lat;
if (document.getElementById("cp_lng")) document.getElementById("cp_lng").value = data.lng;
if (document.getElementById("cp_old_sort_order")) document.getElementById("cp_old_sort_order").value = data.sort_order;
if (document.getElementById("cp_name")) document.getElementById("cp_name").value = data.location_name;
if (document.getElementById("cp_start_date")) document.getElementById("cp_start_date").value = data.step_start_date || "";
if (document.getElementById("cp_end_date")) document.getElementById("cp_end_date").value = data.step_end_date || "";
if (document.getElementById("cp_step_type")) {
const type = data.step_type || "stop";
document.getElementById("cp_step_type").value = type;
if (typeof toggleStepDates === 'function') toggleStepDates(type);
}
if (document.getElementById("cp_set_as_return")) {
document.getElementById("cp_set_as_return").checked = (window.GLOBAL_RETURN_STEP_ID == data.sort_order);
}
// Génération avec transmission de la latitude et longitude de chaque dépense
if (data.items && data.items.length > 0) {
let visibleCount = 0;
data.items.forEach((it) => {
if (it.name !== "PF_TECHNICAL_POINT") {
if (typeof window.addCpExpenseLine === 'function') {
window.addCpExpenseLine(
it.category, it.name, it.amount, it.is_paid, it.notes || "",
it.id || "", it.item_date || "", it.item_time || "", it.duration || 1,
it.expense_context || "local", it.lat || "", it.lng || ""
);
}
visibleCount++;
}
});
if (visibleCount === 0 && typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
} else {
if (typeof window.addCpExpenseLine === 'function') window.addCpExpenseLine();
}
}
try {
if (typeof cpDateRangePicker !== 'undefined' && cpDateRangePicker && typeof cpDateRangePicker.destroy === 'function') {
cpDateRangePicker.destroy();
}
} catch (e) {}
const modal = document.getElementById("checkpointModal");
if (modal) {
modal.style.display = "flex";
document.body.classList.add("no-scroll");
}
};
// 3. LA FONCTION AVEC LA CLASSE CSS PARFAITE ET LE BOUTON 📍
window.addCpExpenseLine = function(category = "accommodation", name = "", amount = "", isPaid = 0, notes = "", itemId = "", itemDate = "", itemTime = "", itemDur = 1, expenseContext = "local", itemLat = "", itemLng = "") {
const container = document.getElementById("cpExpensesContainer");
if (!container) return;
const div = document.createElement("div");
div.className = "hol-form-row"; // Force l'utilisation du CSS
const isChecked = isPaid == 1 ? "checked" : "";
// Indicateur visuel (bouton vert) si on a déjà des coordonnées pour cette activité
const pinBtnStyle = (itemLat && itemLng) ? "background:var(--success);color:white;border-color:var(--success);" : "";
div.innerHTML = `
<div class="hol-form-inner">
<select name="items[cat][]" class="pf-input hol-form-select">
<option value="accommodation" ${category === "accommodation" ? "selected" : ""}>🏨</option>
<option value="transport" ${category === "transport" ? "selected" : ""}>🚗</option>
<option value="activity" ${category === "activity" ? "selected" : ""}>🎫</option>
</select>
<input type="hidden" name="items[context][]" value="${expenseContext}">
<input type="text" name="items[name][]" class="pf-input hol-form-text" placeholder="${window.I18N['hdl_js_ph_expense_name'] || 'Nom de la dépense'}" value="${name}" required>
<button type="button" class="btn-icon-action" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border-light);border-radius:6px; margin:0; flex-shrink:0; ${pinBtnStyle}" onclick="window.toggleItemSearch(this)" title="Adresse précise">📍</button>
<input type="number" step="0.01" name="items[amount][]" class="pf-input hol-form-number" placeholder="0.00" value="${amount}" required>
<label class="hol-form-paid-label" title="${window.I18N['hdl_paid'] || 'Payé'}">
<input type="checkbox" ${isChecked} onchange="this.nextElementSibling.value = this.checked ? 1 : 0" style="width:16px;height:16px;margin:0;accent-color:var(--primary);">
<input type="hidden" name="items[paid][]" value="${isPaid}">
<span class="hol-form-paid-text">Payé</span>
</label>
<button type="button" onclick="this.parentElement.parentElement.remove()" title="${window.I18N['hdl_js_delete_line'] || 'Supprimer'}" class="btn-icon-action delete btn-remove-expense" style="margin: 0;">🗑️</button>
</div>
<div class="hol-item-search-box" style="display:none; padding: 10px; background: var(--bg-subtle); border-radius: 8px; margin-top: 6px; width: 100%;">
<div style="display:flex; gap:10px;">
<input type="text" class="pf-input item-search-input" placeholder="Chercher une adresse..." onkeypress="if(event.key === 'Enter') { window.searchItemPlace(this); return false; }">
<button type="button" class="pf-btn btn-secondary" onclick="window.searchItemPlace(this)">🔍</button>
</div>
<div class="item-search-results" style="margin-top:10px; max-height:150px; overflow-y:auto;"></div>
</div>
<div class="hol-form-subrow" style="display: flex; padding-left: 58px; margin-top: 6px;">
<input type="text" name="items[notes][]" class="pf-input hol-form-notes-input" placeholder="${window.I18N['hdl_ph_notes'] || 'Notes...'}" value="${notes}" style="font-size: 0.8rem; padding: 6px 8px; border-style: dashed; color: var(--text-muted); width: 100%; background: transparent; margin: 0;">
</div>
<input type="hidden" name="items[id][]" value="${itemId}">
<input type="hidden" name="items[date][]" value="${itemDate}">
<input type="hidden" name="items[time][]" value="${itemTime}">
<input type="hidden" name="items[duration][]" value="${itemDur}">
<input type="hidden" name="items[lat][]" value="${itemLat}">
<input type="hidden" name="items[lng][]" value="${itemLng}">
`;
container.appendChild(div);
};
// 4. RÉINTÉGRATION DES FONCTIONS DE GÉOCODAGE DANS LE MÊME SCOPE
window.toggleItemSearch = function(btn) {
const searchBox = btn.closest(".hol-form-row").querySelector(".hol-item-search-box");
searchBox.style.display = searchBox.style.display === "none" ? "block" : "none";
};
window.searchItemPlace = async function(btn) {
const container = btn.closest(".hol-item-search-box");
const input = container.querySelector(".item-search-input");
const resultsDiv = container.querySelector(".item-search-results");
const q = input.value.trim();
if (q.length < 3) return;
resultsDiv.innerHTML = `<span style="color:#64748b; font-size:0.85rem;">Recherche en cours...</span>`;
try {
const data = await pachaFetch("/modules/holidays/includes/api/geocode.php?limit=5&q=" + encodeURIComponent(q), { method: 'GET' });
resultsDiv.innerHTML = "";
if (data.error || !data.results || data.results.length === 0) {
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">Aucun résultat</span>`;
return;
}
data.results.forEach((place) => {
const b = document.createElement("button");
b.type = "button";
b.className = "pf-btn btn-secondary";
b.style.textAlign = "left";
b.style.padding = "8px";
b.style.marginBottom = "4px";
b.style.width = "100%";
b.innerText = "📍 " + place.display_name;
b.onclick = () => window.selectItemPlace(container, place.lat, place.lng, place.display_name);
resultsDiv.appendChild(b);
});
} catch (err) {
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">Erreur de connexion</span>`;
}
};
window.selectItemPlace = function(container, lat, lng, displayName) {
const row = container.closest(".hol-form-row");
row.querySelector('input[name="items[lat][]"]').value = lat;
row.querySelector('input[name="items[lng][]"]').value = lng;
// Si le champ note est vide, on l'utilise pour y placer le nom complet de l'adresse
const notesInput = row.querySelector('input[name="items[notes][]"]');
if (notesInput.value === "") {
notesInput.value = displayName;
}
container.style.display = "none";
// Le bouton devient vert pour confirmer la présence de coordonnées
const pinBtn = row.querySelector('.btn-icon-action[title="Adresse précise"]');
if (pinBtn) {
pinBtn.style.background = "var(--success)";
pinBtn.style.color = "white";
pinBtn.style.borderColor = "var(--success)";
}
};
window.closeCheckpointModal = function() {
const modal = document.getElementById('checkpointModal');
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll');
};
window.closePlanningModal = function() {
const modal = document.getElementById('planningModal');
if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll');
};
});
</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';
document.body.classList.remove('no-scroll');
};
</script>
<script src="/modules/holidays/holidays.js"></script>
<?php
function renderHolidayCard($h, $pdo) {
@@ -136,8 +137,9 @@ function renderHolidayCard($h, $pdo) {
$dateDisplay = htmlspecialchars($h['period_hint'] ?? '');
if (empty($dateDisplay) && $h['start_date']) {
$dateDisplay = date('d/m/Y', strtotime($h['start_date']));
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($h['end_date']));
// 💡 Format Jour/Mois uniquement
$dateDisplay = date('d/m', strtotime($h['start_date']));
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m', strtotime($h['end_date']));
}
$statusClass = match($h['status']) {
@@ -180,7 +182,7 @@ function renderHolidayCard($h, $pdo) {
<span>🗓️ ".($dateDisplay ?: tr('hdl_dates_to_define'))."</span>
</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;'>
<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>
+23 -48
View File
@@ -1,5 +1,5 @@
<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>
<form action="/modules/holidays/includes/api/save_holiday.php" method="POST" id="holidayForm">
@@ -7,11 +7,11 @@
<input type="hidden" name="action" value="save">
<div class="form-row">
<div style="flex: 2;">
<div class="hol-flex-2">
<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>
</div>
<div style="flex: 1;">
<div class="hol-flex-1">
<label class="pf-label"><?= tr('hdl_label_status') ?></label>
<select name="status" id="inp_status" class="pf-input">
<option value="draft"><?= tr('hdl_status_draft') ?> ✏️</option>
@@ -24,70 +24,45 @@
</div>
<div class="form-row">
<div>
<div class="hol-flex-1">
<label class="pf-label"><?= tr('hdl_label_period') ?></label>
<input type="text" name="period_hint" id="inp_period" class="pf-input" placeholder="<?= tr('hdl_ph_period') ?>">
</div>
<div style="display:flex; gap:10px;">
<div style="flex:1">
<label class="pf-label"><?= tr('hdl_label_from') ?></label>
<input type="date" name="start_date" id="inp_start" class="pf-input">
</div>
<div style="flex:1">
<label class="pf-label"><?= tr('hdl_label_to') ?></label>
<input type="date" name="end_date" id="inp_end" class="pf-input">
</div>
<!-- DATES AVEC FLATPICKR -->
<div class="hol-flex-2">
<label class="pf-label">📅 Dates du voyage</label>
<input type="text" id="hol_date_range" class="pf-input" placeholder="Sélectionnez les dates..." readonly style="cursor: pointer; background-color: var(--bg-panel); color: var(--primary); font-weight: bold; text-align: center; letter-spacing: 0.5px;">
<!-- Vraies données pour save_holiday.php -->
<input type="hidden" name="start_date" id="inp_start">
<input type="hidden" name="end_date" id="inp_end">
</div>
</div>
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;">
<div class="hol-columns-wrapper">
<div class="hol-col">
<div class="hol-col-header">
<h4 style="color:#2563eb;">🚗 <?= tr('hdl_cat_transport') ?></h4>
<button type="button" class="btn-add-item" onclick="addItem('transport')" title="<?= tr('hdl_add_transport') ?>"></button>
</div>
<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 class="form-group" style="margin-top: 15px; padding: 10px; background: var(--bg-subtle); border-radius: 8px; border: 1px solid var(--border-light);">
<label class="pf-label" style="margin-bottom: 5px;">🚗 Véhicule utilisé (Optionnel)</label>
<select name="vehicle_id" id="inp_vehicle_id" class="pf-input">
<option value="">-- Aucun / Autre transport --</option>
<?php foreach($garageVehicles as $v): ?>
<option value="<?= $v['id'] ?>"><?= htmlspecialchars($v['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;">
<hr class="hol-divider">
<div class="form-row">
<div>
<div class="hol-flex-1">
<label class="pf-label">🍔 <?= tr('hdl_label_budget_food') ?></label>
<input type="number" step="0.01" name="budget_food" id="inp_food" class="pf-input" placeholder="0.00">
</div>
<div>
<div class="hol-flex-1">
<label class="pf-label">🎁 <?= tr('hdl_label_budget_extras') ?></label>
<input type="number" step="0.01" name="budget_extra" id="inp_extra" class="pf-input" placeholder="0.00">
</div>
</div>
<div class="form-group">
<label class="pf-label"><?= tr('hdl_label_notes') ?></label>
<textarea name="notes" id="inp_notes" class="pf-input" rows="2" placeholder="<?= tr('hdl_ph_notes') ?>"></textarea>
</div>
<div class="modal-footer">
<button type="button" onclick="deleteHoliday()" id="btn_delete" class="pf-btn btn-secondary" 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="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</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;
/* 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 */
@@ -18,51 +18,13 @@ body[data-page="home"]::before {
content: "";
position: fixed;
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 */
}
/* === 2. HEADER SPÉCIFIQUE (Translucide) === */
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);
}
/* Header : identique au reste du site (global.css + dark-mode) */
/* Adaptation des couleurs des liens du header sur fond sombre */
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) === */
/* === 2. HERO SECTION (Message de Bienvenue) === */
.pf-hero {
margin-top: 40px;
margin-bottom: 60px;
@@ -70,7 +32,7 @@ body[data-page="home"] .pf-logout-btn:hover {
}
.pf-hero h1 {
color: #ffffff;
color: var(--text-on-primary);
font-size: 2.5rem;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); /* Ombre pour détacher le texte de l'image */
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);
}
/* === 4. GRILLE DES MODULES === */
/* === 3. GRILLE DES MODULES === */
/* Titre de section "Modules" */
body[data-page="home"] .pf-modules-grid {
@@ -100,7 +62,7 @@ body[data-page="home"] .pf-modules-grid {
}
.pf-module-card:hover {
background: #ffffff;
background: var(--bg-panel);
transform: translateY(-5px);
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);
}
/* === 5. MOBILE RESPONSIVE === */
/* === 4. MOBILE RESPONSIVE === */
@media (max-width: 768px) {
body[data-page="home"] {
/* Image spécifique mobile */
@@ -131,9 +93,4 @@ body[data-page="home"] .pf-modules-grid {
.pf-hero h1 {
font-size: clamp(1.8rem, 6vw, 2.5rem); /* Typographie fluide */
}
/* Ajustement header mobile */
body[data-page="home"] .pf-header {
padding: 0 16px;
}
}
+441
View File
@@ -0,0 +1,441 @@
<?php
ob_start();
require_once dirname(__DIR__, 2) . '/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__, 2) . '/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();
}
// Built-in keyword → category position mapping (position maps to seeded order above)
function liste_builtin_detect(string $normalized): ?int {
static $map = null;
if ($map === null) $map = [
// 1 = Fruits & Légumes
'pomme'=>1,'poire'=>1,'banane'=>1,'orange'=>1,'citron'=>1,'raisin'=>1,
'fraise'=>1,'framboise'=>1,'myrtille'=>1,'cerise'=>1,'peche'=>1,'abricot'=>1,
'mangue'=>1,'kiwi'=>1,'ananas'=>1,'melon'=>1,'pasteque'=>1,'prune'=>1,
'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,
// 2 = Viande
'boeuf'=>2,'veau'=>2,'poulet'=>2,'porc'=>2,'agneau'=>2,'dinde'=>2,
'lapin'=>2,'jambon'=>2,'lardon'=>2,'saucisse'=>2,'saucisson'=>2,
'merguez'=>2,'chipolata'=>2,'steak'=>2,'escalope'=>2,'roti'=>2,
'gigot'=>2,'filet'=>2,'cuisses'=>2,'viande hachee'=>2,'andouille'=>2,
'magret'=>2,'canard'=>2,'foie'=>2,'boudin'=>2,'paupiette'=>2,
'entrecote'=>2,'cote de boeuf'=>2,'onglet'=>2,'bavette'=>2,
// 3 = Poissonnerie
'saumon'=>3,'thon'=>3,'dorade'=>3,'bar'=>3,'cabillaud'=>3,'sole'=>3,
'truite'=>3,'lieu'=>3,'maquereau'=>3,'sardine'=>3,'anchois'=>3,
'crevette'=>3,'moule'=>3,'huitre'=>3,'coquille'=>3,'poulpe'=>3,
'seiche'=>3,'langoustine'=>3,'lotte'=>3,'merlu'=>3,'colin'=>3,
'tilapia'=>3,'daurade'=>3,'aiglefin'=>3,'homard'=>3,'crabe'=>3,
// 4 = Boulangerie
'pain'=>4,'baguette'=>4,'brioche'=>4,'croissant'=>4,'pain de mie'=>4,
'ficelle'=>4,'fougasse'=>4,'ciabatta'=>4,'biscotte'=>4,'naan'=>4,
'pita'=>4,'toast'=>4,'bagel'=>4,'pain burger'=>4,'wrap'=>4,
// 5 = Frais
'lait'=>5,'creme'=>5,'yaourt'=>5,'yourt'=>5,'beurre'=>5,
'fromage'=>5,'camembert'=>5,'brie'=>5,'comte'=>5,'gruyere'=>5,
'emmental'=>5,'mozzarella'=>5,'ricotta'=>5,'mascarpone'=>5,
'feta'=>5,'chevre'=>5,'oeuf'=>5,'oeufs'=>5,'creme fraiche'=>5,
'fromage blanc'=>5,'petit suisse'=>5,'kefir'=>5,
// 6 = Surgelés
'surgele'=>6,'pizza surgelee'=>6,'frite surgelee'=>6,'poisson pane'=>6,
'legume surgele'=>6,'glace'=>6,'sorbet'=>6,'plat surgele'=>6,
'nugget'=>6,'edamame'=>6,'petits pois'=>6,'epinards'=>6,
// 7 = Boissons
'eau'=>7,'coca'=>7,'cola'=>7,'limonade'=>7,'sirop'=>7,
'biere'=>7,'vin'=>7,'champagne'=>7,'cafe'=>7,'the'=>7,
'tisane'=>7,'soda'=>7,'jus'=>7,'kombucha'=>7,'cidre'=>7,
'whisky'=>7,'rhum'=>7,'vodka'=>7,'prosecco'=>7,'rose'=>7,
// 8 = Pâtes, Riz, Féculents
'pate'=>8,'spaghetti'=>8,'tagliatelle'=>8,'penne'=>8,'fusilli'=>8,
'macaroni'=>8,'riz'=>8,'couscous'=>8,'quinoa'=>8,'lentille'=>8,
'pois chiche'=>8,'farine'=>8,'semoule'=>8,'polenta'=>8,'boulgour'=>8,
'orge'=>8,'feculent'=>8,'vermicelle'=>8,'nouille'=>8,'gnocchi'=>8,
// 9 = Épicerie salée
'chips'=>9,'crackers'=>9,'olives'=>9,'cornichon'=>9,'capres'=>9,
'tapenade'=>9,'houmous'=>9,'tzatziki'=>9,'sel'=>9,'poivre'=>9,
'epice'=>9,'cube bouillon'=>9,'bouillon'=>9,'noix de cajou'=>9,
'amande'=>9,'pistache'=>9,'noix'=>9,'cacahuete'=>9,
// 10 = Conserves
'conserve'=>10,'boite de tomate'=>10,'concentre de tomate'=>10,
'mais'=>10,'ratatouille'=>10,'cassoulet'=>10,'pate de foie'=>10,
'sardine boite'=>10,'thon boite'=>10,'maquereau boite'=>10,
'soupe boite'=>10,'haricot boite'=>10,
// 11 = Plats cuisinés
'quiche'=>11,'lasagne'=>11,'gratin'=>11,'pizza'=>11,'tartiflette'=>11,
'croque'=>11,'hachis parmentier'=>11,'paella'=>11,'moussaka'=>11,
'tarte'=>11,'flamiche'=>11,'pissaladiere'=>11,
// 12 = Sauces & Condiments
'ketchup'=>12,'mayonnaise'=>12,'moutarde'=>12,'vinaigrette'=>12,
'sauce soja'=>12,'tabasco'=>12,'huile'=>12,'vinaigre'=>12,
'sauce tomate'=>12,'pesto'=>12,'sriracha'=>12,'worcester'=>12,
'nuoc mam'=>12,'sauce'=>12,'condiment'=>12,'worcestershire'=>12,
// 13 = Petit déjeuner
'cereale'=>13,'muesli'=>13,'granola'=>13,'corn flakes'=>13,
'miel'=>13,'confiture'=>13,'nutella'=>13,'beurre de cacahuete'=>13,
'sirop d erable'=>13,'sirop erable'=>13,'chocolat en poudre'=>13,
// 14 = Biscuits & Gâteaux
'biscuit'=>14,'gateau'=>14,'cookie'=>14,'madeleine'=>14,
'financier'=>14,'quatre quarts'=>14,'brownie'=>14,'sable'=>14,
'speculoos'=>14,'oreo'=>14,'lu'=>14,'petit beurre'=>14,'galette'=>14,
'palmier'=>14,'macaron'=>14,'eclair'=>14,
// 15 = Confiserie
'chocolat'=>15,'bonbon'=>15,'reglisse'=>15,'caramel'=>15,
'nougat'=>15,'marshmallow'=>15,'guimauve'=>15,'sucette'=>15,
'chewing gum'=>15,'pastille'=>15,'calisson'=>15,
// 16 = Dessert
'yaourt dessert'=>16,'creme dessert'=>16,'mousse au chocolat'=>16,
'tiramisu'=>16,'creme brulee'=>16,'flan'=>16,'crepe'=>16,
'madeleine'=>16,'profiterole'=>16,'eclair'=>16,
// 17 = Beauté & Hygiène
'shampoing'=>17,'gel douche'=>17,'savon'=>17,'dentifrice'=>17,
'deodorant'=>17,'crème visage'=>17,'crème corps'=>17,'rasoir'=>17,
'mousse a raser'=>17,'coton'=>17,'lingette'=>17,'maquillage'=>17,
'parfum'=>17,'brosse a dents'=>17,'fil dentaire'=>17,'serum'=>17,
'hydratant'=>17,'demaquillant'=>17,
// 18 = Bébé
'couche'=>18,'biberon'=>18,'lait infantile'=>18,'compote bebe'=>18,
'pot bebe'=>18,'puree bebe'=>18,'lingette bebe'=>18,'savon bebe'=>18,
'creme bebe'=>18,'sucette bebe'=>18,
// 19 = Entretien
'lessive'=>19,'liquide vaisselle'=>19,'nettoyant'=>19,'degraissant'=>19,
'deboucheur'=>19,'anticalcaire'=>19,'eponge'=>19,'serpillere'=>19,
'sac poubelle'=>19,'papier toilette'=>19,'sopalin'=>19,'essuie tout'=>19,
'aluminium'=>19,'film plastique'=>19,'sac congelation'=>19,
'nettoyant wc'=>19,'desinfectant'=>19,'vitre'=>19,'javel'=>19,
// 20 = Animaux
'croquette'=>20,'patee'=>20,'litiere'=>20,'os'=>20,
'friandise animale'=>20,'nourriture chat'=>20,'nourriture chien'=>20,
// 21 = Maison & Jardin
'ampoule'=>21,'pile'=>21,'bougie'=>21,'allumette'=>21,
'ruban adhesif'=>21,'colle'=>21,'vis'=>21,'terreau'=>21,
'engrais'=>21,'pot de fleur'=>21,'arrosoir'=>21,'graine'=>21,
'tournevis'=>21,'marteau'=>21,'clou'=>21,'cle'=>21,
// 22 = Pharmacie
'doliprane'=>22,'ibuprofene'=>22,'paracetamol'=>22,'aspirine'=>22,
'bandage'=>22,'pansement'=>22,'thermometre'=>22,'serum physiologique'=>22,
'vitamine'=>22,'complement'=>22,'masque'=>22,'gant medical'=>22,
'sirop'=>22,'antihistaminique'=>22,'antidouleur'=>22,
];
// Check full phrase
if (isset($map[$normalized])) return $map[$normalized];
// Check individual words
$words = explode(' ', $normalized);
foreach ($words as $w) {
if (strlen($w) >= 3 && isset($map[$w])) return $map[$w];
}
// Partial match (contains)
foreach ($map as $kw => $catPos) {
if (str_contains($normalized, $kw)) return $catPos;
}
return null;
}
function liste_detect_category(PDO $pdo, string $label): ?int {
$norm = liste_normalize_detect($label);
// 1. Learned rules (exact normalized label)
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
$r->execute([$norm]);
if ($row = $r->fetch()) return (int)$row['category_id'];
// 2. Learned rules (individual words)
foreach (explode(' ', $norm) as $w) {
if (strlen($w) < 3) continue;
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
$r->execute([$w]);
if ($row = $r->fetch()) return (int)$row['category_id'];
}
// 3. Built-in dictionary → map position to real category id
$pos = liste_builtin_detect($norm);
if ($pos === null) return null;
// Get category id by position order
$r = $pdo->query("SELECT id FROM pf_list_categories ORDER BY position, id");
$ids = $r->fetchAll(PDO::FETCH_COLUMN);
return $ids[$pos - 1] ?? null;
}
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]);
// Also learn individual meaningful words
foreach (explode(' ', $norm) as $w) {
if (strlen($w) >= 4) {
$pdo->prepare("INSERT IGNORE INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)")
->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]);
// Learn from manual assignment
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='setting' AND reference_id='liste_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]);
+575
View File
@@ -0,0 +1,575 @@
/* ── Module Liste ─────────────────────────────────────────────────────────── */
.liste-main {
max-width: 680px;
margin: 0 auto;
padding: 1rem 1rem 4rem;
}
/* ── Tabs bar ──────────────────────────────────────────────────────────────── */
.liste-tabs-bar {
display: flex;
align-items: center;
gap: .4rem;
margin-bottom: .75rem;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.liste-tabs-bar::-webkit-scrollbar { display: none; }
.liste-tabs {
display: flex;
gap: .35rem;
flex-wrap: wrap;
}
.liste-tab {
display: flex;
align-items: center;
gap: .25rem;
padding: .4rem .75rem;
border-radius: 20px;
border: 1px solid var(--border, #dee2e6);
background: var(--card-bg, #fff);
cursor: pointer;
font-size: .875rem;
white-space: nowrap;
transition: background .15s, border-color .15s;
user-select: none;
}
.liste-tab:hover { background: var(--hover-bg, #f8f9fa); }
.liste-tab.active {
background: var(--primary, #4361ee);
color: #fff;
border-color: var(--primary, #4361ee);
font-weight: 600;
}
.liste-tab-name { pointer-events: none; }
.liste-tab-btn {
background: none;
border: none;
padding: 0 .1rem;
cursor: pointer;
font-size: .8rem;
color: rgba(255,255,255,.75);
line-height: 1;
transition: color .15s;
}
.liste-tab-btn:hover { color: #fff; }
.liste-tab-add {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px dashed var(--border, #dee2e6);
background: none;
cursor: pointer;
font-size: 1.1rem;
color: var(--muted, #6c757d);
display: flex;
align-items: center;
justify-content: center;
transition: border-color .15s, color .15s;
line-height: 1;
}
.liste-tab-add:hover {
border-color: var(--primary, #4361ee);
color: var(--primary, #4361ee);
}
/* ── New list form ─────────────────────────────────────────────────────────── */
.liste-new-form {
display: flex;
gap: .5rem;
align-items: center;
margin-bottom: .75rem;
flex-wrap: wrap;
}
.liste-new-form input {
flex: 1;
min-width: 160px;
padding: .45rem .75rem;
border: 1px solid var(--primary, #4361ee);
border-radius: 8px;
font-size: .9rem;
background: var(--input-bg, #fff);
color: var(--text, #212529);
}
.liste-new-form button {
padding: .45rem .9rem;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: .875rem;
}
.liste-new-form #btn-confirm-new-list {
background: var(--primary, #4361ee);
color: #fff;
}
.liste-new-form #btn-cancel-new-list {
background: var(--muted-bg, #e9ecef);
color: var(--text, #212529);
}
.hidden { display: none !important; }
/* ── Add card ──────────────────────────────────────────────────────────────── */
.liste-add-card {
display: flex;
gap: .5rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 12px;
padding: .6rem .75rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.05);
}
.liste-add-card input {
flex: 1;
border: none;
outline: none;
font-size: .95rem;
background: transparent;
color: var(--text, #212529);
}
.liste-add-card button {
background: var(--primary, #4361ee);
color: #fff;
border: none;
border-radius: 8px;
padding: .4rem .9rem;
cursor: pointer;
font-size: .875rem;
font-weight: 600;
white-space: nowrap;
}
/* ── Toolbar ───────────────────────────────────────────────────────────────── */
.liste-toolbar {
display: flex;
gap: .4rem;
flex-wrap: wrap;
margin-bottom: .75rem;
}
.btn-tool {
padding: .35rem .75rem;
border-radius: 8px;
border: 1px solid var(--border, #dee2e6);
background: var(--card-bg, #fff);
cursor: pointer;
font-size: .8rem;
color: var(--text, #212529);
transition: background .15s;
}
.btn-tool:hover { background: var(--hover-bg, #f8f9fa); }
.btn-tool-danger { color: var(--danger, #e63946); border-color: var(--danger, #e63946); }
.btn-tool-danger:hover { background: rgba(230,57,70,.08); }
/* ── Items ─────────────────────────────────────────────────────────────────── */
.liste-row {
display: flex;
align-items: center;
gap: .5rem;
padding: .55rem .75rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 10px;
margin-bottom: .4rem;
transition: opacity .2s;
}
.liste-row.in-cart { opacity: .55; }
.liste-row.in-cart .liste-label { text-decoration: line-through; }
.liste-check-label {
display: flex;
align-items: center;
gap: .6rem;
flex: 1;
cursor: pointer;
}
.liste-check {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--primary, #4361ee);
cursor: pointer;
flex-shrink: 0;
}
.liste-label { font-size: .95rem; }
.liste-row-actions {
display: flex;
gap: .25rem;
opacity: 0;
transition: opacity .15s;
}
.liste-row:hover .liste-row-actions,
.liste-row:focus-within .liste-row-actions { opacity: 1; }
.btn-edit-item, .btn-delete-item {
background: none;
border: none;
cursor: pointer;
padding: .2rem .3rem;
border-radius: 6px;
font-size: .9rem;
transition: background .15s;
}
.btn-edit-item:hover { background: var(--hover-bg, #f0f0f0); }
.btn-delete-item:hover { background: rgba(230,57,70,.1); }
.liste-cart-divider {
font-size: .78rem;
color: var(--muted, #6c757d);
text-transform: uppercase;
letter-spacing: .04em;
padding: .5rem .25rem .25rem;
margin-top: .25rem;
}
.liste-empty-items {
text-align: center;
padding: 2rem 1rem;
color: var(--muted, #6c757d);
}
.liste-empty-items span { font-size: 2rem; display: block; margin-bottom: .5rem; }
/* ── History ───────────────────────────────────────────────────────────────── */
.liste-history-card {
margin-top: 1.25rem;
padding: .75rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 12px;
}
.liste-history-title {
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted, #6c757d);
margin-bottom: .5rem;
}
.liste-history-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
.liste-chip {
padding: .25rem .65rem;
border-radius: 20px;
border: 1px solid var(--border, #dee2e6);
background: var(--muted-bg, #f8f9fa);
cursor: pointer;
font-size: .82rem;
color: var(--text, #212529);
transition: background .15s, border-color .15s;
}
.liste-chip:hover {
background: var(--primary-light, #eef0fd);
border-color: var(--primary, #4361ee);
color: var(--primary, #4361ee);
}
/* ── Toast ─────────────────────────────────────────────────────────────────── */
.liste-toast-container {
position: fixed;
bottom: 1.5rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: .4rem;
z-index: 9999;
}
.liste-toast {
padding: .55rem 1rem;
border-radius: 8px;
font-size: .875rem;
background: #333;
color: #fff;
box-shadow: 0 3px 12px rgba(0,0,0,.2);
opacity: 0;
transform: translateY(8px);
transition: opacity .2s, transform .2s;
}
.liste-toast.show { opacity: 1; transform: translateY(0); }
.liste-toast-warn { background: #f0a500; }
.liste-toast-error { background: #e63946; }
/* ── Empty state (no lists at all) ────────────────────────────────────────── */
.liste-empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--muted, #6c757d);
}
.liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; }
/* ── List modal ────────────────────────────────────────────────────────────── */
.liste-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.liste-modal {
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 14px;
width: 100%;
max-width: 420px;
box-shadow: 0 20px 60px rgba(0,0,0,.2);
}
.liste-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border, #dee2e6);
}
.liste-modal-header h3 { margin: 0; font-size: .95rem; font-weight: 700; color: var(--text, #212529); }
.liste-modal-close {
background: none;
border: none;
font-size: 1.3rem;
cursor: pointer;
color: var(--muted, #6c757d);
line-height: 1;
padding: 0 .25rem;
transition: color .15s;
}
.liste-modal-close:hover { color: var(--text, #212529); }
.liste-modal-body {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .85rem;
}
.liste-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: .5rem;
padding: .85rem 1.25rem;
border-top: 1px solid var(--border, #dee2e6);
}
.liste-modal-footer .btn-tool-danger { margin-right: auto; }
.liste-form-group { display: flex; flex-direction: column; gap: .3rem; }
.liste-form-label {
font-size: .73rem;
font-weight: 700;
color: var(--muted, #6c757d);
text-transform: uppercase;
letter-spacing: .04em;
}
.liste-modal-name, .liste-modal-type {
padding: .5rem .7rem;
border: 1px solid var(--border, #dee2e6);
border-radius: 8px;
background: var(--input-bg, #fff);
color: var(--text, #212529);
font-size: .875rem;
font-family: inherit;
width: 100%;
box-sizing: border-box;
transition: border-color .15s;
}
.liste-modal-name:focus, .liste-modal-type:focus {
outline: none;
border-color: var(--primary, #4361ee);
box-shadow: 0 0 0 3px rgba(67,97,238,.15);
}
.btn-tool-primary {
background: var(--primary, #4361ee);
color: #fff;
border-color: var(--primary, #4361ee);
}
.btn-tool-primary:hover { filter: brightness(1.1); background: var(--primary, #4361ee); }
/* ── Color picker ──────────────────────────────────────────────────────────── */
.liste-color-picker {
display: flex;
flex-wrap: wrap;
gap: .45rem;
}
.liste-color-swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 3px solid transparent;
background: var(--swatch, transparent);
cursor: pointer;
transition: transform .1s;
outline: 2px solid transparent;
outline-offset: 2px;
box-sizing: border-box;
}
.liste-color-swatch:hover { transform: scale(1.2); }
.liste-color-swatch.active {
outline-color: var(--swatch, var(--primary, #4361ee));
border-color: var(--card-bg, #fff);
}
.liste-color-swatch-none {
background: transparent !important;
border: 2px dashed var(--border, #dee2e6) !important;
}
.liste-color-swatch-none.active { outline-color: var(--primary, #4361ee); }
/* ── Tab type emoji ────────────────────────────────────────────────────────── */
.liste-tab-type { font-size: .85rem; line-height: 1; }
/* ── Category section headers ──────────────────────────────────────────────── */
.liste-cat-section-header {
font-size: .75rem;
font-weight: 700;
color: var(--muted, #6c757d);
text-transform: uppercase;
letter-spacing: .05em;
padding: .6rem .25rem .2rem;
display: flex;
align-items: center;
gap: .4rem;
}
.liste-cat-section-header:first-child { padding-top: .1rem; }
.liste-cat-section-count {
background: var(--muted-bg, #e9ecef);
color: var(--muted, #6c757d);
border-radius: 999px;
font-size: .7rem;
padding: .05rem .4rem;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
/* ── Category badge (per-item) ─────────────────────────────────────────────── */
.liste-cat-badge {
flex-shrink: 0;
background: none;
border: 1px solid var(--border, #dee2e6);
border-radius: 6px;
padding: .1rem .3rem;
cursor: pointer;
font-size: .85rem;
line-height: 1.4;
color: inherit;
transition: border-color .15s, background .15s;
}
.liste-cat-badge:hover {
border-color: var(--primary, #4361ee);
background: var(--primary-light, #eef0fd);
}
.liste-cat-badge-empty {
opacity: .35;
border-style: dashed;
}
.liste-row:hover .liste-cat-badge-empty { opacity: .7; }
/* ── Category picker popover ───────────────────────────────────────────────── */
.liste-cat-picker {
position: fixed;
z-index: 9999;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,.15);
width: 216px;
overflow: hidden;
}
.liste-cat-picker-title {
font-size: .72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted, #6c757d);
padding: .6rem .75rem .35rem;
border-bottom: 1px solid var(--border, #dee2e6);
}
.liste-cat-picker-list {
overflow-y: auto;
max-height: 300px;
padding: .3rem;
}
.liste-cat-picker-item {
display: flex;
align-items: center;
gap: .4rem;
width: 100%;
text-align: left;
background: none;
border: none;
border-radius: 6px;
padding: .4rem .6rem;
font-size: .83rem;
color: var(--text, #212529);
cursor: pointer;
transition: background .1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.liste-cat-picker-item:hover { background: var(--hover-bg, #f8f9fa); }
.liste-cat-picker-item.active {
background: var(--primary-light, #eef0fd);
color: var(--primary, #4361ee);
font-weight: 600;
}
/* ── Dark mode ─────────────────────────────────────────────────────────────── */
[data-theme='dark'] .liste-tab { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .liste-tab:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-tab.active { background: #4361ee; color: #fff; border-color: #4361ee; }
[data-theme='dark'] .liste-add-card,
[data-theme='dark'] .liste-row,
[data-theme='dark'] .liste-history-card { background: #1e1e2e; border-color: #3a3a4e; }
[data-theme='dark'] .liste-add-card input { color: #cdd6f4; }
[data-theme='dark'] .liste-chip { background: #2a2a3e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .liste-chip:hover { background: #313155; border-color: #4361ee; color: #89b4fa; }
[data-theme='dark'] .btn-tool { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .btn-tool:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; }
[data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; }
[data-theme='dark'] .liste-modal { background: #1e1e2e; border-color: #3a3a4e; }
[data-theme='dark'] .liste-modal-header,
[data-theme='dark'] .liste-modal-footer { border-color: #3a3a4e; }
[data-theme='dark'] .liste-modal-header h3 { color: #cdd6f4; }
[data-theme='dark'] .liste-modal-name,
[data-theme='dark'] .liste-modal-type { background: #2a2a3e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .liste-color-swatch.active { border-color: #1e1e2e; }
[data-theme='dark'] .liste-cat-badge { border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; }
[data-theme='dark'] .liste-cat-section-count { background: #2a2a3e; color: #8892b0; }
[data-theme='dark'] .liste-cat-picker { background: #1e1e2e; border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-picker-title { border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-picker-item { color: #cdd6f4; }
[data-theme='dark'] .liste-cat-picker-item:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-cat-picker-item.active { background: #313155; color: #89b4fa; }
+517
View File
@@ -0,0 +1,517 @@
/* Liste module JS */
const API = '/modules/liste/api.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();
+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); }
+410
View File
@@ -0,0 +1,410 @@
// HouseHub — Memo module
const API = '/modules/memo/api.php';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function escHtml(s){ const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML; }
function fmtDate(d){ if(!d)return'--'; try{return new Date(d).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});}catch{return d;} }
function fmtSize(b){ if(!b)return''; if(b<1024)return b+'o'; if(b<1048576)return Math.round(b/1024)+'ko'; return (b/1048576).toFixed(1)+'Mo'; }
function toast(msg,type='success'){
const c=document.getElementById('memo-toasts')||document.body;
const t=document.createElement('div');t.className='memo-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,headers:{}};
if(data&&!(data instanceof FormData)){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(data);}
else if(data instanceof FormData){opts.body=data;}
const r=await fetch(API+'?action='+action+extra,opts);
const j=await r.json();
if(!j.ok)throw new Error(j.error||'Erreur API');
return j.data;
}
// ─── State ───────────────────────────────────────────────────────────────────
let currentNoteId = null;
let currentPage = 'list';
let currentQ = '';
let currentTag = '';
let allTagsList = [];
let pendingFiles = [];
let pendingUrls = [];
// ─── Navigation ──────────────────────────────────────────────────────────────
function showPage(name){
document.querySelectorAll('.memo-page').forEach(p=>p.classList.remove('active'));
const el=document.getElementById('memo-page-'+name);
if(el)el.classList.add('active');
currentPage=name;
}
// ─── Sidebar ─────────────────────────────────────────────────────────────────
async function loadSidebar(){
try{
allTagsList=await api('tags');
const el=document.getElementById('memo-tags-list');
if(!el)return;
const allActive=currentTag===''&&currentQ==='';
let html=`<div class="memo-tag-item${allActive?' active':''}" onclick="filterTag('')">
📝 Toutes les notes <span class="memo-tag-count" id="memo-all-count"></span></div>`;
allTagsList.forEach(t=>{
const act=currentTag===t.tag?' active':'';
html+=`<div class="memo-tag-item${act}" onclick="filterTag('${escHtml(t.tag)}')">
<span># ${escHtml(t.tag)}</span><span class="memo-tag-count">${t.count}</span></div>`;
});
el.innerHTML=html;
}catch(e){}
}
// ─── Note list ────────────────────────────────────────────────────────────────
async function loadList(q='',tag='',page=1){
currentQ=q;currentTag=tag;showPage('list');
loadSidebar();
try{
let extra='&page='+page;
if(q)extra+='&q='+encodeURIComponent(q);
if(tag)extra+='&tag='+encodeURIComponent(tag);
const data=await api('notes','GET',null,extra);
const el=document.getElementById('memo-notes-grid');
const count=document.getElementById('memo-all-count');
const title=document.getElementById('memo-list-title');
const subtitle=document.getElementById('memo-list-subtitle');
if(count)count.textContent=data.total;
if(title){
title.textContent = q?`🔍 "${q}"` : tag?`# ${tag}` : '📝 Notes';
}
if(subtitle)subtitle.textContent=data.total+' note'+(data.total!==1?'s':'');
if(!data.notes.length){
el.innerHTML='<div class="memo-empty"><div class="icon">📝</div><p>'+(q||tag?'Aucune note trouvée.':'Aucune note. Créez-en une !')+'</p></div>';
return;
}
el.innerHTML=data.notes.map(n=>noteCardHtml(n)).join('');
// Pagination
const total_pages=Math.ceil(data.total/data.per_page);
const pag=document.getElementById('memo-pagination');
if(pag){
pag.innerHTML=total_pages>1?
(page>1?`<button class="btn btn-secondary btn-sm" onclick="loadList('${escHtml(q)}','${escHtml(tag)}',${page-1})">← Précédent</button>`:'<span></span>')+
`<span style="color:var(--text-muted);font-size:.82rem">Page ${page} / ${total_pages}</span>`+
(page<total_pages?`<button class="btn btn-secondary btn-sm" onclick="loadList('${escHtml(q)}','${escHtml(tag)}',${page+1})">Suivant →</button>`:'<span></span>')
: '';
}
}catch(e){toast(e.message,'error');}
}
function noteCardHtml(n){
const tags=(n.tags||'').split(',').filter(t=>t.trim()).map(t=>
`<span class="note-tag-chip" onclick="event.stopPropagation();filterTag('${escHtml(t.trim())}')">#${escHtml(t.trim())}</span>`).join('');
const snippet=n.snippet?(escHtml(n.snippet.replace(/\n/g,' '))):'';
return `<div class="note-card" onclick="viewNote(${n.id})">
<div class="note-card-title">${escHtml(n.title)}</div>
${snippet?`<div class="note-card-snippet">${snippet}</div>`:''}
<div class="note-card-meta">${tags}<span class="note-card-date">${fmtDate(n.updated_at)}</span></div>
</div>`;
}
function filterTag(tag){
currentTag=tag;currentQ='';
document.getElementById('memo-search-input')?.value!=null&&(document.getElementById('memo-search-input').value='');
loadList('',tag);
}
function doSearch(){
const q=document.getElementById('memo-search-input')?.value.trim()||'';
loadList(q,'');
}
// ─── Note view ────────────────────────────────────────────────────────────────
async function viewNote(id){
currentNoteId=id;showPage('view');
try{
const note=await api('notes','GET',null,'&id='+id);
document.getElementById('view-title').textContent=note.title;
document.getElementById('view-date').textContent='Modifié '+fmtDate(note.updated_at);
// Tags
const tagEl=document.getElementById('view-tags');
tagEl.innerHTML=(note.tags||'').split(',').filter(t=>t.trim()).map(t=>
`<span class="note-tag-chip" onclick="filterTag('${escHtml(t.trim())}')">#${escHtml(t.trim())}</span>`).join('');
// Markdown rendering
const mdEl=document.getElementById('view-md');
if(note.content&&window.marked){
mdEl.innerHTML=marked.parse(note.content);
mdEl.classList.remove('empty');
} else if(note.content){
mdEl.textContent=note.content;mdEl.classList.remove('empty');
} else {
mdEl.innerHTML='<em>Aucun contenu.</em>';mdEl.classList.add('empty');
}
// Attachments
renderAttachmentsView(note.attachments||[],id);
}catch(e){toast(e.message,'error');}
}
function renderAttachmentsView(atts,noteId){
const images=atts.filter(a=>a.type==='image');
const files =atts.filter(a=>a.type==='file');
const urls =atts.filter(a=>a.type==='url');
const el=document.getElementById('view-attachments');
el.innerHTML='';
if(!atts.length){el.style.display='none';return;}
el.style.display='block';
if(images.length){
el.innerHTML+=`<div class="memo-att-section">
<div class="memo-att-title">🖼 Images</div>
<div class="att-img-grid">${images.map(a=>`
<div class="att-img-item" onclick="openLightbox('${API}?action=file&id=${a.id}')">
<img src="${API}?action=file&id=${a.id}" alt="${escHtml(a.original_name||'')}">
<button class="att-del" onclick="event.stopPropagation();deleteAttachment(${a.id},${noteId})" title="Supprimer"></button>
</div>`).join('')}</div></div>`;
}
if(files.length){
el.innerHTML+=`<div class="memo-att-section">
<div class="memo-att-title">📎 Fichiers</div>
<div class="att-file-list">${files.map(a=>`
<div class="att-file-item">
<a href="${API}?action=file&id=${a.id}" target="_blank">${escHtml(a.original_name||a.filename)}</a>
<span class="att-file-size">${fmtSize(a.size)}</span>
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${noteId})"></button>
</div>`).join('')}</div></div>`;
}
if(urls.length){
el.innerHTML+=`<div class="memo-att-section">
<div class="memo-att-title">🔗 Liens</div>
<div class="att-url-list">${urls.map(a=>`
<div class="att-url-item">
<a href="${escHtml(a.url)}" target="_blank" rel="noopener">${escHtml(a.label||a.url)}</a>
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${noteId})"></button>
</div>`).join('')}</div></div>`;
}
}
async function deleteAttachment(attId,noteId){
if(!confirm('Supprimer cet attachement ?'))return;
try{ await api('attachments','DELETE',null,'&id='+attId); toast('Supprimé'); viewNote(noteId); }
catch(e){toast(e.message,'error');}
}
// Lightbox
function openLightbox(src){
const lb=document.getElementById('memo-lightbox');
const img=lb.querySelector('img');
img.src=src;lb.classList.add('show');
}
function closeLightbox(){ document.getElementById('memo-lightbox')?.classList.remove('show'); }
// ─── Edit / Create ────────────────────────────────────────────────────────────
let editingId=null;
let editTags=[];
let tagAutocomplete=[];
function openCreate(){
editingId=null;pendingFiles=[];pendingUrls=[];editTags=[];
document.getElementById('edit-page-title').textContent='Nouvelle note';
document.getElementById('edit-title').value='';
document.getElementById('edit-content').value='';
document.getElementById('md-live-preview').innerHTML='';
document.getElementById('edit-tags-chips').innerHTML='';
document.getElementById('edit-tags-input').value='';
document.getElementById('edit-attach-preview').innerHTML='';
document.getElementById('edit-url-rows').innerHTML=addUrlRowHtml();
renderTagChips();
showPage('edit');
document.getElementById('edit-title')?.focus();
}
async function openEdit(id){
editingId=id;pendingFiles=[];pendingUrls=[];editTags=[];
try{
const note=await api('notes','GET',null,'&id='+id);
document.getElementById('edit-page-title').textContent='Modifier la note';
document.getElementById('edit-title').value=note.title;
document.getElementById('edit-content').value=note.content||'';
editTags=(note.tags||'').split(',').filter(t=>t.trim());
renderTagChips();
updatePreview();
// existing attachments shown below form
renderAttachmentsEdit(note.attachments||[]);
document.getElementById('edit-url-rows').innerHTML=addUrlRowHtml();
showPage('edit');
}catch(e){toast(e.message,'error');}
}
function renderAttachmentsEdit(atts){
const el=document.getElementById('edit-existing-attachments');
if(!atts.length){el.innerHTML='';return;}
el.innerHTML=`<div class="memo-att-title" style="margin-bottom:.5rem">Attachements existants</div>`+
atts.map(a=>`<div class="att-file-item">
${a.type==='url'?
`<a href="${escHtml(a.url)}" target="_blank">${escHtml(a.label||a.url)}</a>`:
`<a href="${API}?action=file&id=${a.id}" target="_blank">${escHtml(a.original_name||a.filename)}</a>
<span class="att-file-size">${fmtSize(a.size)}</span>`}
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${editingId})"></button>
</div>`).join('');
}
function updatePreview(){
const content=document.getElementById('edit-content')?.value||'';
const el=document.getElementById('md-live-preview');
if(!el)return;
el.innerHTML=window.marked?marked.parse(content):'<em style="color:var(--text-muted)">Preview chargement…</em>';
}
// Tags
function renderTagChips(){
const wrap=document.getElementById('edit-tags-chips');
wrap.innerHTML=editTags.map(t=>
`<span class="tag-chip">#${escHtml(t)}<button type="button" onclick="removeTag('${escHtml(t)}')" title="Retirer">×</button></span>`
).join('');
}
function removeTag(t){ editTags=editTags.filter(x=>x!==t);renderTagChips(); }
function addTagFromInput(){
const inp=document.getElementById('edit-tags-input');
const val=inp.value.trim().replace(/^#/,'').toLowerCase();
if(val&&!editTags.includes(val)){editTags.push(val);renderTagChips();}
inp.value='';
document.getElementById('tags-suggestions')?.remove();
}
function onTagKeydown(e){
if(e.key==='Enter'||e.key===','||e.key===' '){e.preventDefault();addTagFromInput();}
else if(e.key==='Backspace'&&!e.target.value&&editTags.length){
editTags.pop();renderTagChips();
} else showTagSuggestions(e.target.value);
}
function showTagSuggestions(q){
document.getElementById('tags-suggestions')?.remove();
if(!q||!allTagsList.length)return;
const matches=allTagsList.filter(t=>t.tag.includes(q.toLowerCase())).slice(0,6);
if(!matches.length)return;
const inp=document.getElementById('edit-tags-input');
const box=document.createElement('div');
box.id='tags-suggestions';box.className='tags-suggestions';
box.style.cssText=`position:absolute;top:${inp.offsetTop+inp.offsetHeight+4}px;left:${inp.offsetLeft}px`;
matches.forEach(t=>{const d=document.createElement('div');d.textContent='#'+t.tag;d.onclick=()=>{
if(!editTags.includes(t.tag)){editTags.push(t.tag);renderTagChips();}
inp.value='';box.remove();
};box.appendChild(d);});
inp.closest('.tags-input-wrap').style.position='relative';
inp.closest('.tags-input-wrap').appendChild(box);
}
// File drop
function initDropZone(){
const zone=document.getElementById('edit-drop-zone');
const inp=document.getElementById('edit-file-input');
if(!zone)return;
zone.addEventListener('click',()=>inp?.click());
zone.addEventListener('dragover',e=>{e.preventDefault();zone.classList.add('drag-over');});
zone.addEventListener('dragleave',()=>zone.classList.remove('drag-over'));
zone.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('drag-over');handleFileSelect(e.dataTransfer.files);});
inp?.addEventListener('change',()=>handleFileSelect(inp.files));
document.getElementById('edit-content')?.addEventListener('paste',e=>{
const files=[...e.clipboardData.files].filter(f=>f.type.startsWith('image/'));
if(files.length){e.preventDefault();handleFileSelect(files);}
});
}
function handleFileSelect(files){
const preview=document.getElementById('edit-attach-preview');
[...files].forEach(f=>{
pendingFiles.push(f);
const item=document.createElement('div');item.className='attach-preview-item';
if(f.type.startsWith('image/')){
const img=document.createElement('img');
const reader=new FileReader();
reader.onload=ev=>{img.src=ev.target.result;};
reader.readAsDataURL(f);
item.appendChild(img);
}else{
item.innerHTML=`<div style="background:#f1f5f9;padding:.3rem .5rem;border-radius:6px;font-size:.75rem;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(f.name)}</div>`;
}
const rm=document.createElement('button');rm.className='rm';rm.textContent='✕';
rm.onclick=()=>{pendingFiles=pendingFiles.filter(x=>x!==f);item.remove();};
item.appendChild(rm);
preview.appendChild(item);
});
}
// URL rows
function addUrlRowHtml(){
return `<div class="url-row" style="margin-bottom:.4rem">
<input type="text" class="form-control url-input" placeholder="https://..." style="flex:1.5">
<input type="text" class="form-control url-label" placeholder="Libellé (optionnel)" style="flex:1">
<button type="button" class="btn btn-secondary btn-sm" onclick="addUrlRow()">+</button>
</div>`;
}
function addUrlRow(){
const wrap=document.getElementById('edit-url-rows');
wrap.insertAdjacentHTML('beforeend',addUrlRowHtml());
}
// Save
async function saveNote(){
const title=document.getElementById('edit-title')?.value.trim();
if(!title){toast('Titre requis','error');return;}
const content=document.getElementById('edit-content')?.value||'';
const tags=editTags.join(',');
// Collect URLs
const urlRows=document.querySelectorAll('#edit-url-rows .url-row');
const urls=[];
urlRows.forEach(row=>{
const u=row.querySelector('.url-input')?.value.trim();
const l=row.querySelector('.url-label')?.value.trim()||'';
if(u)urls.push({url:u,label:l});
});
try{
let noteId;
if(editingId){
await api('notes','PUT',{title,content,tags},'&id='+editingId);
noteId=editingId;toast('Note mise à jour');
}else{
const r=await api('notes','POST',{title,content,tags,urls});
noteId=r.id;toast('Note créée');
}
// Upload pending files
for(const f of pendingFiles){
const fd=new FormData();fd.append('file',f);fd.append('note_id',noteId);
try{await api('attachments','POST',fd);}catch(e){toast('Erreur upload: '+f.name,'error');}
}
// Add URLs (for edit mode)
if(editingId){
for(const u of urls){
const fd=new FormData();fd.append('note_id',noteId);fd.append('url',u.url);fd.append('label',u.label);
try{await api('attachments','POST',fd);}catch{}
}
}
loadList(currentQ,currentTag);
viewNote(noteId);
}catch(e){toast(e.message,'error');}
}
async function deleteNote(id){
if(!confirm('Supprimer cette note définitivement ?'))return;
try{
await api('notes','DELETE',null,'&id='+id);
toast('Note supprimée');loadList(currentQ,currentTag);
}catch(e){toast(e.message,'error');}
}
// ─── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded',()=>{
loadList();
loadSidebar();
initDropZone();
// Search
const sinp=document.getElementById('memo-search-input');
if(sinp){
let t;sinp.addEventListener('input',()=>{clearTimeout(t);t=setTimeout(()=>doSearch(),300);});
sinp.addEventListener('keydown',e=>{if(e.key==='Enter')doSearch();});
}
// Content live preview
document.getElementById('edit-content')?.addEventListener('input',updatePreview);
// Lightbox close
document.getElementById('memo-lightbox')?.addEventListener('click',closeLightbox);
// Tags input
document.getElementById('edit-tags-input')?.addEventListener('keydown',onTagKeydown);
document.getElementById('edit-tags-input')?.addEventListener('blur',()=>setTimeout(()=>document.getElementById('tags-suggestions')?.remove(),150));
document.addEventListener('click',e=>{if(!e.target.closest('.tags-input-wrap'))document.getElementById('tags-suggestions')?.remove();});
});
+174
View File
@@ -0,0 +1,174 @@
<?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';
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
function tOk($d) { ob_end_clean(); echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE); exit; }
function tErr($m, $c = 400) { ob_end_clean(); http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
function tBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
function planka_api($method, $path, $body = null, $token = null) {
$url = 'http://10.200.33.10:1337' . $path;
$headers = ['Content-Type: application/json'];
if ($token) $headers[] = 'Authorization: Bearer ' . $token;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 10,
]);
if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$result = curl_exec($ch);
curl_close($ch);
return json_decode($result, true) ?? [];
}
function planka_token(PDO $pdo) {
$row = $pdo->query("SELECT admin_token, token_expires_at FROM pf_planka_config WHERE id=1")->fetch(PDO::FETCH_ASSOC);
if ($row && $row['token_expires_at'] > date('Y-m-d H:i:s')) return $row['admin_token'];
$resp = planka_api('POST', '/api/access-tokens', ['emailOrUsername' => 'admin', 'password' => 'ideine']);
$token = $resp['item'] ?? null;
if (!$token) return null;
$exp = date('Y-m-d H:i:s', strtotime('+30 days'));
$pdo->prepare("INSERT INTO pf_planka_config (id,admin_token,token_expires_at) VALUES (1,?,?) ON DUPLICATE KEY UPDATE admin_token=VALUES(admin_token),token_expires_at=VALUES(token_expires_at)")->execute([$token, $exp]);
return $token;
}
$token = planka_token($pdo);
if (!$token) tErr('Impossible d\'obtenir un token Planka', 503);
if ($action === 'boards') {
$cfg = $pdo->query("SELECT project_id FROM pf_planka_config WHERE id=1")->fetch(PDO::FETCH_ASSOC);
$project_id = $cfg['project_id'] ?? '1713338747178714130';
$resp = planka_api('GET', '/api/projects/' . $project_id, null, $token);
$boards = [];
if (isset($resp['included']['boards'])) {
foreach ($resp['included']['boards'] as $b) {
$boards[] = ['id' => $b['id'], 'name' => $b['name']];
}
}
tOk($boards);
}
if ($action === 'board') {
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
$resp = planka_api('GET', '/api/boards/' . $id, null, $token);
$inc = $resp['included'] ?? [];
// Filter: only lists with a real name (Planka creates unnamed "None" placeholder lists)
$lists = array_values(array_filter($inc['lists'] ?? [], fn($l) =>
!empty($l['name']) && $l['name'] !== 'None' &&
(isset($l['boardId']) ? $l['boardId'] === $id : true)
));
// Filter cards that belong to a list in this board
$listIds = array_column($lists, 'id');
$cards = array_values(array_filter($inc['cards'] ?? [], fn($c) =>
in_array($c['listId'] ?? '', $listIds)
));
tOk([
'lists' => $lists,
'cards' => $cards,
'labels' => $inc['labels'] ?? [],
'members' => $inc['users'] ?? [],
'cardLabels' => $inc['cardLabels'] ?? [],
'tasks' => $inc['tasks'] ?? [],
]);
}
if ($action === 'set_active_board') {
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
$pdo->prepare("INSERT INTO pf_planka_config (id, active_board_id) VALUES (1,?) ON DUPLICATE KEY UPDATE active_board_id=VALUES(active_board_id)")->execute([$id]);
tOk(['updated' => true]);
}
if ($action === 'create_list') {
$board_id = $_GET['board_id'] ?? null; if (!$board_id) tErr('board_id manquant');
$name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant');
$position = (float) ($_GET['position'] ?? 65535);
$resp = planka_api('POST', '/api/boards/' . $board_id . '/lists', [
'name' => $name,
'position' => $position,
'type' => 'active',
], $token);
if (empty($resp['item'])) tErr($resp['message'] ?? ($resp['problems'][0] ?? 'Erreur Planka'), 502);
tOk($resp['item']);
}
if ($action === 'create_card') {
$list_id = $_GET['list_id'] ?? null; if (!$list_id) tErr('list_id manquant');
$name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant');
$pos = (float) ($_GET['position'] ?? 65535);
// Planka v2+: POST /api/lists/{listId}/cards with type required
$resp = planka_api('POST', '/api/lists/' . $list_id . '/cards', [
'name' => $name,
'position' => $pos,
'type' => 'story',
], $token);
if (empty($resp['item'])) tErr($resp['message'] ?? ($resp['problems'][0] ?? 'Erreur Planka'), 502);
tOk($resp['item']);
}
if ($action === 'update_card') {
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
$d = tBody();
$payload = [];
if (isset($d['name'])) $payload['name'] = $d['name'];
if (isset($d['description'])) $payload['description'] = $d['description'];
if (array_key_exists('dueDate', $d)) $payload['dueDate'] = $d['dueDate'];
if (isset($d['listId'])) $payload['listId'] = $d['listId'];
if (isset($d['position'])) $payload['position'] = (float)$d['position'];
$resp = planka_api('PATCH', '/api/cards/' . $id, $payload, $token);
if (empty($resp['item'])) tErr($resp['message'] ?? ($resp['problems'][0] ?? 'Planka a rejeté la mise à jour'), 502);
tOk($resp['item']);
}
if ($action === 'delete_card') {
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
planka_api('DELETE', '/api/cards/' . $id, null, $token);
tOk(['deleted' => true]);
}
if ($action === 'create_task') {
$card_id = $_GET['card_id'] ?? null; if (!$card_id) tErr('card_id manquant');
$name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant');
$resp = planka_api('POST', '/api/cards/' . $card_id . '/tasks', [
'name' => $name,
'position' => 65535,
], $token);
tOk($resp['item'] ?? []);
}
if ($action === 'toggle_task') {
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
$is_completed = ($_GET['is_completed'] ?? '0') === '1';
$resp = planka_api('PATCH', '/api/tasks/' . $id, ['isCompleted' => $is_completed], $token);
tOk($resp['item'] ?? []);
}
if ($action === 'set_project') {
$project_id = $_GET['project_id'] ?? null; if (!$project_id) tErr('project_id manquant');
$pdo->prepare("INSERT INTO pf_planka_config (id, project_id) VALUES (1,?) ON DUPLICATE KEY UPDATE project_id=VALUES(project_id)")->execute([$project_id]);
tOk(['updated' => true]);
}
if ($action === 'config') {
$row = $pdo->query("SELECT project_id, active_board_id FROM pf_planka_config WHERE id=1")->fetch(PDO::FETCH_ASSOC);
tOk($row ?: ['project_id' => '1713338747178714130', 'active_board_id' => null]);
}
tErr('Action inconnue', 404);
+520
View File
@@ -0,0 +1,520 @@
.pk-page {
display: flex;
flex-direction: column;
height: calc(100vh - 64px);
overflow: hidden;
background: var(--bg-page);
}
.pk-topbar {
display: flex;
align-items: center;
gap: .75rem;
padding: .65rem 1.25rem;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
flex-wrap: wrap;
}
.pk-topbar h1 {
font-size: 1rem;
font-weight: 700;
color: var(--text-main);
margin: 0;
}
.pk-board-selector {
display: flex;
gap: .4rem;
flex-wrap: wrap;
flex: 1;
}
.pk-board-btn {
padding: .3rem .75rem;
border-radius: 999px;
border: 1px solid var(--border-light);
background: var(--bg-page);
color: var(--text-muted);
cursor: pointer;
font-size: .82rem;
font-weight: 500;
transition: all .15s;
}
.pk-board-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.pk-board-btn.active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.pk-topbar-actions {
display: flex;
align-items: center;
gap: .5rem;
margin-left: auto;
}
.pk-btn-icon {
background: none;
border: 1px solid var(--border-light);
border-radius: 8px;
padding: .3rem .5rem;
cursor: pointer;
color: var(--text-muted);
font-size: .9rem;
transition: all .15s;
}
.pk-btn-icon:hover {
border-color: var(--primary);
color: var(--primary);
}
.pk-board-area {
display: flex;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
padding: 1rem;
gap: 1rem;
align-items: flex-start;
}
.pk-board-area::-webkit-scrollbar { height: 8px; }
.pk-board-area::-webkit-scrollbar-track { background: transparent; }
.pk-board-area::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 4px; }
.pk-list {
flex-shrink: 0;
width: 272px;
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 12px;
display: flex;
flex-direction: column;
max-height: 100%;
}
.pk-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .65rem .85rem;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.pk-list-name {
font-size: .85rem;
font-weight: 700;
color: var(--text-main);
}
.pk-list-count {
font-size: .72rem;
color: var(--text-muted);
background: var(--bg-page);
border-radius: 999px;
padding: .1rem .45rem;
}
.pk-cards {
overflow-y: auto;
padding: .5rem;
display: flex;
flex-direction: column;
gap: .4rem;
flex: 1;
}
.pk-cards::-webkit-scrollbar { width: 4px; }
.pk-cards::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
.pk-card {
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 8px;
padding: .6rem .75rem;
cursor: pointer;
transition: all .15s;
user-select: none;
}
.pk-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(59,130,246,.12);
transform: translateY(-1px);
}
.pk-card.is-dragging {
opacity: .5;
transform: rotate(2deg);
}
.pk-card-labels {
display: flex;
gap: .25rem;
flex-wrap: wrap;
margin-bottom: .35rem;
}
.pk-label {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 4px;
font-size: .72rem;
font-weight: 600;
color: #fff;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0,0,0,.3);
min-width: 36px;
}
.pk-label:empty {
height: 6px;
width: 36px;
padding: 0;
}
.pk-card-name {
font-size: .83rem;
font-weight: 500;
color: var(--text-main);
line-height: 1.4;
}
.pk-card-meta {
display: flex;
align-items: center;
gap: .5rem;
margin-top: .4rem;
flex-wrap: wrap;
}
.pk-due {
font-size: .72rem;
color: var(--text-muted);
}
.pk-due.overdue { color: #ef4444; }
.pk-due.soon { color: #f97316; }
.pk-task-count {
font-size: .72rem;
color: var(--text-muted);
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 999px;
padding: .05rem .4rem;
}
.pk-add-card-btn {
display: flex;
align-items: center;
gap: .4rem;
width: 100%;
padding: .5rem .85rem;
background: none;
border: none;
border-top: 1px solid var(--border-light);
border-radius: 0 0 12px 12px;
cursor: pointer;
color: var(--text-muted);
font-size: .82rem;
text-align: left;
transition: background .15s;
flex-shrink: 0;
}
.pk-add-card-btn:hover {
background: var(--bg-page);
color: var(--primary);
}
.pk-drop-zone {
min-height: 4px;
transition: min-height .15s;
}
.pk-drop-zone.drag-over {
min-height: 48px;
border: 2px dashed var(--primary);
border-radius: 8px;
background: rgba(59,130,246,.06);
}
.pk-loading {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-muted);
font-size: .9rem;
gap: .75rem;
}
.pk-spinner {
width: 28px;
height: 28px;
border: 3px solid var(--border-light);
border-top-color: var(--primary);
border-radius: 50%;
animation: pkSpin .7s linear infinite;
}
@keyframes pkSpin { to { transform: rotate(360deg); } }
.pk-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 1000;
padding: 2rem 1rem;
overflow-y: auto;
}
.pk-modal-backdrop.hidden { display: none; }
.pk-modal {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 14px;
width: 100%;
max-width: 520px;
box-shadow: 0 20px 60px rgba(0,0,0,.2);
}
.pk-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light);
}
.pk-modal-header h3 { margin: 0; font-size: .95rem; color: var(--text-main); }
.pk-modal-close {
background: none;
border: none;
font-size: 1.3rem;
cursor: pointer;
color: var(--text-muted);
line-height: 1;
padding: 0 .25rem;
}
.pk-modal-body {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .85rem;
}
.pk-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: .5rem;
padding: .85rem 1.25rem;
border-top: 1px solid var(--border-light);
}
.pk-form-group { display: flex; flex-direction: column; gap: .3rem; }
.pk-form-label { font-size: .75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; }
.pk-input, .pk-textarea, .pk-select {
padding: .5rem .7rem;
border: 1px solid var(--border-light);
border-radius: 8px;
background: var(--bg-page);
color: var(--text-main);
font-size: .875rem;
font-family: inherit;
transition: border-color .15s;
width: 100%;
box-sizing: border-box;
}
.pk-input:focus, .pk-textarea:focus, .pk-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59,130,246,.15);
}
.pk-textarea { resize: vertical; min-height: 80px; }
.pk-checklist { display: flex; flex-direction: column; gap: .35rem; }
.pk-task-item {
display: flex;
align-items: center;
gap: .55rem;
padding: .3rem .5rem;
border-radius: 6px;
transition: background .1s;
}
.pk-task-item:hover { background: var(--bg-page); }
.pk-task-cb {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary);
flex-shrink: 0;
}
.pk-task-label {
font-size: .83rem;
color: var(--text-main);
flex: 1;
}
.pk-task-label.done {
text-decoration: line-through;
color: var(--text-muted);
}
.pk-add-task-row {
display: flex;
gap: .4rem;
margin-top: .25rem;
}
.pk-add-task-row .pk-input { flex: 1; }
.pk-btn {
padding: .45rem 1rem;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: .83rem;
font-weight: 600;
transition: all .15s;
}
.pk-btn-primary {
background: var(--primary);
color: #fff;
}
.pk-btn-primary:hover { filter: brightness(1.1); }
.pk-btn-secondary {
background: var(--bg-page);
border: 1px solid var(--border-light);
color: var(--text-main);
}
.pk-btn-secondary:hover { border-color: var(--primary); color: var(--primary); }
.pk-btn-danger {
background: #ef4444;
color: #fff;
margin-right: auto;
}
.pk-btn-danger:hover { filter: brightness(1.1); }
.pk-btn-sm {
padding: .3rem .65rem;
font-size: .78rem;
}
.pk-label-color--berry-red { background: #ef4444; }
.pk-label-color--fresh-salad { background: #22c55e; }
.pk-label-color--pumpkin-orange { background: #f97316; }
.pk-label-color--morning-sky { background: #3b82f6; }
.pk-label-color--pink-tulip { background: #ec4899; }
.pk-label-color--midnight-blue { background: #1e40af; }
.pk-label-color--lagoon-blue { background: #0ea5e9; }
.pk-label-color--purpley { background: #a855f7; }
.pk-label-color--egg-white { background: #fef3c7; }
.pk-label-color--anti-flash-white { background: #f1f5f9; }
[data-theme="dark"] .pk-modal { background: var(--bg-panel); }
[data-theme="dark"] .pk-list { background: var(--bg-panel); }
[data-theme="dark"] .pk-card { background: var(--bg-page); }
[data-theme="dark"] .pk-input,
[data-theme="dark"] .pk-textarea,
[data-theme="dark"] .pk-select { background: var(--bg-page); color: var(--text-main); }
@media (max-width: 768px) {
.pk-list { width: 240px; }
.pk-page { height: auto; overflow: visible; }
.pk-board-area { overflow-x: auto; height: auto; padding-bottom: 4rem; }
}
/* ── Drag & Drop amélioré ──────────────────────────────────────────────────── */
.pk-cards.drag-over {
background: rgba(59,130,246,.07);
outline: 2px dashed var(--primary, #4361ee);
outline-offset: -2px;
border-radius: 8px;
min-height: 80px;
}
.pk-card.is-dragging { opacity: .4; }
.pk-drop-hint {
text-align: center;
color: var(--text-muted, #9ca3af);
font-size: .78rem;
padding: .75rem;
border: 2px dashed var(--border-light, #e5e7eb);
border-radius: 8px;
margin-bottom: .35rem;
}
.pk-list-new {
background: transparent;
border: 2px dashed var(--border-light, #e5e7eb);
display: flex;
align-items: flex-start;
justify-content: center;
padding: .75rem .5rem;
}
.pk-add-list-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted, #9ca3af);
font-size: .9rem;
font-weight: 600;
padding: .4rem .75rem;
border-radius: 8px;
transition: background .15s, color .15s;
white-space: nowrap;
}
.pk-add-list-btn:hover {
background: var(--bg-panel, #f3f4f6);
color: var(--primary, #4361ee);
}
.pk-drop-before {
height: 4px;
border-radius: 4px;
transition: height .12s, background .12s;
margin: -2px 0;
}
.pk-drop-before.drop-before-active {
height: 28px;
background: rgba(59,130,246,.15);
border: 2px dashed var(--primary, #4361ee);
border-radius: 6px;
margin: 2px 0;
}
/* ── Indicateurs drop sur carte ────────────────────────────────────────────── */
.pk-card.drop-top { border-top: 3px solid var(--primary, #4361ee) !important; }
.pk-card.drop-bottom { border-bottom: 3px solid var(--primary, #4361ee) !important; }
+338
View File
@@ -0,0 +1,338 @@
<?php
ob_start();
require_once dirname(__DIR__, 2) . '/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__, 2) . '/includes/db.php';
define('PV_MODEL_DIR', '/uploads/printvault/models/');
define('PV_THUMB_DIR', '/uploads/printvault/thumbs/');
function pvOk($d) { ob_end_clean(); echo json_encode(['ok'=>true,'data'=>$d], JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_SUBSTITUTE); exit; }
function pvErr($m,$c=400){ ob_end_clean(); http_response_code($c); echo json_encode(['ok'=>false,'error'=>$m]); exit; }
function pvBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
// ── STL parser ─────────────────────────────────────────────────────────────────
function parseSTL(string $path): array {
$data = file_get_contents($path, false, null, 0, 204);
if ($data === false || strlen($data) < 6) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
$isAscii = strtolower(substr(ltrim($data), 0, 5)) === 'solid';
$size = filesize($path);
if ($size > 30 * 1024 * 1024) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; // skip >30MB
$data = file_get_contents($path);
if ($isAscii) return parseSTLAscii($data);
return parseSTLBinary($data);
}
function parseSTLBinary(string $data): array {
if (strlen($data) < 84) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
$n = unpack('V', substr($data, 80, 4))[1];
if (strlen($data) < 84 + $n * 50 || $n === 0) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
$mins = [PHP_FLOAT_MAX, PHP_FLOAT_MAX, PHP_FLOAT_MAX];
$maxs = [-PHP_FLOAT_MAX, -PHP_FLOAT_MAX, -PHP_FLOAT_MAX];
$vol = 0.0;
for ($i = 0; $i < $n; $i++) {
$off = 84 + $i * 50;
$v1 = array_values(unpack('f3', substr($data, $off+12, 12)));
$v2 = array_values(unpack('f3', substr($data, $off+24, 12)));
$v3 = array_values(unpack('f3', substr($data, $off+36, 12)));
for ($j = 0; $j < 3; $j++) {
$vals = [$v1[$j], $v2[$j], $v3[$j]];
$mins[$j] = min($mins[$j], ...$vals);
$maxs[$j] = max($maxs[$j], ...$vals);
}
$vol += ($v1[0]*($v2[1]*$v3[2]-$v2[2]*$v3[1])
-$v1[1]*($v2[0]*$v3[2]-$v2[2]*$v3[0])
+$v1[2]*($v2[0]*$v3[1]-$v2[1]*$v3[0])) / 6.0;
}
return [
'dim_x' => round($maxs[0]-$mins[0], 2),
'dim_y' => round($maxs[1]-$mins[1], 2),
'dim_z' => round($maxs[2]-$mins[2], 2),
'volume'=> round(abs($vol)/1000.0, 3),
];
}
function parseSTLAscii(string $data): array {
preg_match_all('/vertex\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)/', $data, $m);
if (empty($m[1])) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
$xs = array_map('floatval', $m[1]);
$ys = array_map('floatval', $m[2]);
$zs = array_map('floatval', $m[3]);
return [
'dim_x' => round(max($xs)-min($xs), 2),
'dim_y' => round(max($ys)-min($ys), 2),
'dim_z' => round(max($zs)-min($zs), 2),
'volume'=> 0,
];
}
// ── 3MF parser ─────────────────────────────────────────────────────────────────
function parse3MF(string $path): array {
if (!class_exists('ZipArchive')) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
if (filesize($path) > 50 * 1024 * 1024) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
$z = new ZipArchive();
if ($z->open($path) !== true) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
$xml = null;
for ($i = 0; $i < $z->numFiles; $i++) {
if (str_ends_with($z->getNameIndex($i), '.model')) {
$xml = $z->getFromIndex($i); break;
}
}
$z->close();
if (!$xml) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
libxml_use_internal_errors(true);
$dom = simplexml_load_string($xml);
if (!$dom) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
preg_match_all('/x="([-\d.eE+]+)"/', $xml, $mx);
preg_match_all('/y="([-\d.eE+]+)"/', $xml, $my);
preg_match_all('/z="([-\d.eE+]+)"/', $xml, $mz);
if (empty($mx[1])) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
$xs = array_map('floatval', $mx[1]); $ys = array_map('floatval', $my[1]); $zs = array_map('floatval', $mz[1]);
return [
'dim_x' => round(max($xs)-min($xs), 2),
'dim_y' => round(max($ys)-min($ys), 2),
'dim_z' => round(max($zs)-min($zs), 2),
'volume'=> 0,
];
}
// ── GCode parser ───────────────────────────────────────────────────────────────
function parseGCode(string $path): array {
$time = $filament = $nozzle = $bed = '';
$handle = fopen($path, 'r');
$lines = 0;
while (!feof($handle) && $lines < 300) {
$line = fgets($handle); $lines++;
if (preg_match('/;TIME:(\d+)/', $line, $m)) {
$s = (int)$m[1]; $time = ($s>=3600 ? floor($s/3600).'h':'').sprintf('%02d', ($s%3600)/60).'m';
} elseif (preg_match('/;estimated printing time[^=]*=\s*(.+)/i', $line, $m)) {
$time = trim($m[1]);
} elseif (preg_match('/;Filament used:\s*([\d.]+)m/i', $line, $m)) {
$filament = $m[1].'m';
} elseif (preg_match('/;filament used \[mm\]\s*=\s*([\d.]+)/i', $line, $m)) {
$filament = round((float)$m[1]/1000, 2).'m';
} elseif (preg_match('/M104\s+S(\d+)/i', $line, $m) && !$nozzle) { $nozzle = $m[1].'°C'; }
elseif (preg_match('/M109\s+S(\d+)/i', $line, $m) && !$nozzle) { $nozzle = $m[1].'°C'; }
elseif (preg_match('/M140\s+S(\d+)/i', $line, $m) && !$bed) { $bed = $m[1].'°C'; }
elseif (preg_match('/M190\s+S(\d+)/i', $line, $m) && !$bed) { $bed = $m[1].'°C'; }
}
// Scan tail for slicer comments
fseek($handle, -min(8192, filesize($path)), SEEK_END);
while (!feof($handle)) {
$line = fgets($handle);
if (!$time && preg_match('/;TIME:(\d+)/', $line, $m)) { $s=(int)$m[1]; $time=($s>=3600?floor($s/3600).'h':'').sprintf('%02d',($s%3600)/60).'m'; }
if (!$filament && preg_match('/;Filament used:\s*([\d.]+)m/i', $line, $m)) $filament = $m[1].'m';
if (!$filament && preg_match('/;filament used \[mm\]\s*=\s*([\d.]+)/i', $line, $m)) $filament = round((float)$m[1]/1000,2).'m';
}
fclose($handle);
return compact('time', 'filament', 'nozzle', 'bed');
}
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// ── CATEGORIES ─────────────────────────────────────────────────────────────────
if ($action === 'categories') {
if ($method === 'GET') {
$rows = $pdo->query("SELECT c.*, COUNT(m.id) as count FROM pf_pv_categories c LEFT JOIN pf_pv_models m ON m.category=c.name GROUP BY c.id ORDER BY c.name")->fetchAll();
pvOk($rows);
}
if ($method === 'POST') {
$d = pvBody();
$name = trim($d['name'] ?? ''); if (!$name) pvErr('Nom requis');
$pdo->prepare("INSERT IGNORE INTO pf_pv_categories (name,color) VALUES (?,?)")->execute([$name, $d['color'] ?? '#8b5cf6']);
pvOk(['id' => (int)$pdo->lastInsertId()]);
}
}
// ── MODELS ─────────────────────────────────────────────────────────────────────
if ($action === 'models') {
if ($method === 'GET') {
$id = $_GET['id'] ?? null;
if ($id) {
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
$r = $s->fetch(); if (!$r) pvErr('Introuvable', 404);
pvOk($r);
}
$where = []; $params = [];
if ($cat = ($_GET['category'] ?? '')) { $where[] = 'category=?'; $params[] = $cat; }
if ($ft = ($_GET['file_type'] ?? '')) {
if ($ft === 'gcode') { $where[] = "file_type IN ('gcode','gco','g')"; }
else { $where[] = 'file_type=?'; $params[] = $ft; }
}
if ($q = ($_GET['search'] ?? '')) {
$like = '%'.$q.'%';
$where[] = '(name LIKE ? OR description LIKE ? OR tags LIKE ?)';
array_push($params, $like, $like, $like);
}
$sql = "SELECT * FROM pf_pv_models" . ($where ? ' WHERE '.implode(' AND ',$where) : '') . " ORDER BY created_at DESC";
$s = $pdo->prepare($sql); $s->execute($params);
pvOk($s->fetchAll());
}
if ($method === 'POST') {
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) pvErr('Fichier manquant');
$f = $_FILES['file'];
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
$allowed = ['stl','3mf','gcode','gco','g'];
if (!in_array($ext, $allowed)) pvErr('Format non supporté');
$fname = uniqid('pv', true) . '.' . $ext;
$dest = PV_MODEL_DIR . $fname;
if (!move_uploaded_file($f['tmp_name'], $dest)) pvErr('Erreur déplacement fichier');
// Parse geometry / metadata
$geo = ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
$gcode = ['time'=>'','filament'=>'','nozzle'=>'','bed'=>''];
if ($ext === 'stl') $geo = parseSTL($dest);
elseif ($ext === '3mf') $geo = parse3MF($dest);
elseif (in_array($ext, ['gcode','gco','g'])) $gcode = parseGCode($dest);
$name = trim($_POST['name'] ?? pathinfo($f['name'], PATHINFO_FILENAME));
$pdo->prepare("INSERT INTO pf_pv_models (name,description,category,tags,file_type,filename,original_name,file_size,dim_x,dim_y,dim_z,volume,gcode_time,gcode_filament,gcode_nozzle,gcode_bed) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
->execute([
$name,
$_POST['description'] ?? '',
$_POST['category'] ?? 'Non classé',
$_POST['tags'] ?? '',
$ext,
$fname,
$f['name'],
filesize($dest),
$geo['dim_x'], $geo['dim_y'], $geo['dim_z'], $geo['volume'],
$gcode['time'], $gcode['filament'], $gcode['nozzle'], $gcode['bed'],
]);
$id = (int)$pdo->lastInsertId();
$row = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?");
$row->execute([$id]); pvOk($row->fetch());
}
if ($method === 'PUT') {
$id = (int)($_GET['id'] ?? 0); if (!$id) pvErr('ID manquant');
$d = pvBody();
$name = trim($d['name'] ?? ''); if (!$name) pvErr('Nom requis');
$pdo->prepare("UPDATE pf_pv_models SET name=?,description=?,category=?,tags=?,updated_at=NOW() WHERE id=?")
->execute([$name, $d['description']??'', $d['category']??'Non classé', $d['tags']??'', $id]);
pvOk(['updated'=>true]);
}
if ($method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0); if (!$id) pvErr('ID manquant');
$s = $pdo->prepare("SELECT filename, thumb FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
$row = $s->fetch(); if (!$row) pvErr('Introuvable', 404);
@unlink(PV_MODEL_DIR . $row['filename']);
if ($row['thumb']) @unlink(PV_THUMB_DIR . $row['thumb']);
$pdo->prepare("DELETE FROM pf_pv_models WHERE id=?")->execute([$id]);
pvOk(['deleted'=>true]);
}
}
// ── GCODE PATHS ────────────────────────────────────────────────────────────────
if ($action === 'gcode_paths') {
$id = (int)($_GET['id'] ?? 0);
$max = (int)($_GET['max'] ?? 0) ?: PHP_INT_MAX; // no cap by default
$s = $pdo->prepare("SELECT filename, file_type FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
$row = $s->fetch(); if (!$row) pvErr('Introuvable', 404);
$ext = strtolower($row['file_type']);
if (!in_array($ext, ['gcode','gco','g'])) pvErr('Pas un fichier GCode');
$path = PV_MODEL_DIR . $row['filename'];
if (!file_exists($path)) pvErr('Fichier introuvable', 404);
$segments = [];
$x=0.0; $y=0.0; $z=0.0; $e=0.0; $lastE=0.0;
$minZ=PHP_FLOAT_MAX; $maxZ=0.0; $count=0;
$handle = fopen($path, 'r');
while (!feof($handle) && count($segments) < $max) {
$line = trim(fgets($handle));
if (($p = strpos($line, ';')) !== false) $line = substr($line, 0, $p);
$line = trim($line);
if (!$line) continue;
$cmd = strtoupper(strtok($line, ' '));
if ($cmd === 'G92') {
if (preg_match('/E([-\d.]+)/i', $line, $m)) $lastE = (float)$m[1];
continue;
}
if ($cmd !== 'G0' && $cmd !== 'G1') continue;
$nx=$x; $ny=$y; $nz=$z; $ne=$e;
if (preg_match('/X([-\d.]+)/i', $line, $m)) $nx = (float)$m[1];
if (preg_match('/Y([-\d.]+)/i', $line, $m)) $ny = (float)$m[1];
if (preg_match('/Z([-\d.]+)/i', $line, $m)) $nz = (float)$m[1];
if (preg_match('/E([-\d.]+)/i', $line, $m)) $ne = (float)$m[1];
$extruding = ($cmd === 'G1' && $ne > $lastE) ? 1 : 0;
if ($nx !== $x || $ny !== $y || $nz !== $z) {
// Compact flat array: x1,y1,z1,x2,y2,z2,ext (2 decimal precision)
$segments[] = round($x,2); $segments[] = round($y,2); $segments[] = round($z,2);
$segments[] = round($nx,2); $segments[] = round($ny,2); $segments[] = round($nz,2);
$segments[] = $extruding;
$minZ = min($minZ, $nz);
$maxZ = max($maxZ, $nz);
$count++;
}
$x=$nx; $y=$ny; $z=$nz;
if ($ne !== $e) { $lastE = $e = $ne; }
}
fclose($handle);
pvOk(['flat' => $segments, 'min_z' => round($minZ === PHP_FLOAT_MAX ? 0 : $minZ, 2), 'max_z' => round($maxZ, 2), 'total' => $count]);
}
// ── FILE SERVE ─────────────────────────────────────────────────────────────────
if ($action === 'file') {
$id = (int)($_GET['id'] ?? 0);
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
$row = $s->fetch(); if (!$row) { http_response_code(404); exit; }
$path = PV_MODEL_DIR . $row['filename'];
if (!file_exists($path)) { http_response_code(404); exit; }
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime = ['stl'=>'model/stl','3mf'=>'model/3mf','gcode'=>'text/plain','gco'=>'text/plain','g'=>'text/plain'][$ext] ?? 'application/octet-stream';
ob_end_clean();
header('Content-Type: ' . $mime);
header('Content-Disposition: attachment; filename="' . rawurlencode($row['original_name']) . '"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: no-cache');
readfile($path); exit;
}
// ── MODEL FILE FOR VIEWER (inline, no download) ────────────────────────────────
if ($action === 'model_data') {
$id = (int)($_GET['id'] ?? 0);
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
$row = $s->fetch(); if (!$row) { http_response_code(404); exit; }
$path = PV_MODEL_DIR . $row['filename'];
if (!file_exists($path)) { http_response_code(404); exit; }
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime = ['stl'=>'model/stl','3mf'=>'model/3mf'][$ext] ?? 'application/octet-stream';
ob_end_clean();
header('Content-Type: ' . $mime);
header('Content-Disposition: inline; filename="' . rawurlencode($row['filename']) . '"');
header('Cache-Control: private, max-age=3600');
readfile($path); exit;
}
// ── THUMBNAIL SAVE ──────────────────────────────────────────────────────────────
if ($action === 'thumb' && $method === 'POST') {
$id = (int)($_GET['id'] ?? 0);
$s = $pdo->prepare("SELECT id, thumb FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
$row = $s->fetch(); if (!$row) pvErr('Introuvable', 404);
if (empty($_FILES['thumb']) || $_FILES['thumb']['error'] !== UPLOAD_ERR_OK) pvErr('Pas de fichier');
if ($row['thumb']) @unlink(PV_THUMB_DIR . $row['thumb']);
$thumbName = 'thumb_' . uniqid('', true) . '.png';
if (!move_uploaded_file($_FILES['thumb']['tmp_name'], PV_THUMB_DIR . $thumbName)) pvErr('Erreur sauvegarde');
$pdo->prepare("UPDATE pf_pv_models SET thumb=? WHERE id=?")->execute([$thumbName, $id]);
pvOk(['thumb' => $thumbName]);
}
pvErr('Action inconnue', 404);
+190
View File
@@ -0,0 +1,190 @@
/* PrintVault module */
.pv-layout {
display: grid;
grid-template-columns: 220px 1fr;
height: calc(100vh - 64px);
overflow: hidden;
}
.pv-sidebar {
border-right: 1px solid var(--border-light);
background: var(--bg-panel);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.pv-sidebar-top {
padding: .75rem;
border-bottom: 1px solid var(--border-light);
position: sticky; top: 0; background: var(--bg-panel); z-index: 1;
display: flex; flex-direction: column; gap: .5rem;
}
.pv-search {
display: flex; align-items: center; gap: .4rem;
background: var(--bg-page); border: 1px solid var(--border-light);
border-radius: 8px; padding: .35rem .6rem;
}
.pv-search input { border: none; outline: none; background: transparent; font-size: .85rem; color: var(--text-main); flex: 1; }
.pv-search input::placeholder { color: var(--text-muted); }
.pv-main { overflow-y: auto; background: var(--bg-page); }
/* Sidebar nav */
.pv-nav-section { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: .75rem .75rem .25rem; }
.pv-nav-item {
display: flex; align-items: center; gap: .5rem;
padding: .45rem .75rem; cursor: pointer;
font-size: .85rem; color: var(--text-muted);
border-radius: 8px; margin: 1px .5rem;
transition: background .12s, color .12s;
}
.pv-nav-item:hover { background: var(--bg-page); color: var(--text-main); }
.pv-nav-item.active { background: #eff6ff; color: var(--primary); font-weight: 600; }
.pv-nav-badge { margin-left: auto; font-size: .7rem; font-weight: 700; background: var(--bg-page); color: var(--text-muted); padding: 1px 6px; border-radius: 999px; }
.pv-nav-item.active .pv-nav-badge { background: #dbeafe; color: var(--primary); }
.pv-cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
/* File type filter chips */
.pv-type-chips { display: flex; gap: .3rem; flex-wrap: wrap; padding: .25rem .75rem .5rem; }
.pv-chip {
font-size: .72rem; font-weight: 600; padding: .2rem .5rem;
border-radius: 999px; cursor: pointer; border: 1.5px solid var(--border-light);
color: var(--text-muted); transition: all .12s;
}
.pv-chip:hover { border-color: var(--primary); color: var(--primary); }
.pv-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.pv-chip.stl.active { background: #8b5cf6; border-color: #8b5cf6; }
.pv-chip.3mf.active { background: #3b82f6; border-color: #3b82f6; }
.pv-chip.gcode.active { background: #10b981; border-color: #10b981; }
/* Main header */
.pv-main-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.5rem; background: var(--bg-panel);
border-bottom: 1px solid var(--border-light); gap: .75rem; flex-wrap: wrap;
}
.pv-main-header h2 { font-size: 1rem; font-weight: 700; margin: 0; }
.pv-subtitle { font-size: .8rem; color: var(--text-muted); }
/* Models grid */
.pv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1.25rem 1.5rem 2rem;
}
.pv-card {
background: var(--bg-panel); border: 1px solid var(--border-light);
border-radius: 12px; overflow: hidden; cursor: pointer;
transition: border-color .15s, box-shadow .12s;
display: flex; flex-direction: column;
}
.pv-card:hover { border-color: #cbd5e1; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
.pv-card-thumb {
aspect-ratio: 4/3; background: #f1f5f9;
display: flex; align-items: center; justify-content: center;
font-size: 3rem; overflow: hidden;
}
.pv-card-thumb img { width: 100%; height: 100%; object-fit: cover; }
.pv-card-body { padding: .75rem; flex: 1; display: flex; flex-direction: column; gap: .25rem; }
.pv-card-name { font-size: .875rem; font-weight: 600; color: var(--text-main); line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pv-card-meta { font-size: .72rem; color: var(--text-muted); display: flex; gap: .4rem; flex-wrap: wrap; margin-top: auto; padding-top: .25rem; }
.pv-type-badge { font-size: .68rem; font-weight: 700; padding: .1rem .4rem; border-radius: 4px; text-transform: uppercase; }
.pv-type-stl { background: #ede9fe; color: #7c3aed; }
.pv-type-3mf { background: #dbeafe; color: #1d4ed8; }
.pv-type-gcode { background: #dcfce7; color: #15803d; }
.pv-type-g { background: #dcfce7; color: #15803d; }
.pv-type-gco { background: #dcfce7; color: #15803d; }
/* Empty state */
.pv-empty { text-align: center; padding: 4rem 2rem; color: var(--text-muted); }
.pv-empty .icon { font-size: 3rem; opacity: .3; margin-bottom: 1rem; }
/* Modal */
.pv-modal-backdrop {
display: none; position: fixed; inset: 0; z-index: 200;
background: rgba(15,23,42,.5); backdrop-filter: blur(4px);
align-items: center; justify-content: center; padding: 1rem;
}
.pv-modal-backdrop.show { display: flex; }
.pv-modal {
background: var(--bg-panel); border: 1px solid var(--border-light);
border-radius: 14px; width: 100%; max-width: 520px;
max-height: 92vh; display: flex; flex-direction: column;
box-shadow: 0 20px 40px rgba(0,0,0,.15);
animation: pvModalIn .18s ease;
}
@keyframes pvModalIn { from{opacity:0;transform:scale(.96) translateY(-8px)} to{opacity:1;transform:none} }
.pv-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-light); }
.pv-modal-header h3 { font-size: 1rem; font-weight: 700; margin: 0; }
.pv-modal-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.2rem; border-radius: 6px; padding: .2rem .4rem; }
.pv-modal-close:hover { background: var(--bg-page); }
.pv-modal-body { padding: 1.25rem; overflow-y: auto; display: flex; flex-direction: column; gap: .9rem; }
.pv-modal-footer { padding: 1rem 1.25rem; border-top: 1px solid var(--border-light); display: flex; justify-content: flex-end; gap: .5rem; }
/* Upload zone */
.pv-drop-zone {
border: 2px dashed var(--border-light); border-radius: 10px;
padding: 2rem; text-align: center; cursor: pointer;
transition: border-color .15s, background .15s; color: var(--text-muted);
}
.pv-drop-zone:hover, .pv-drop-zone.over { border-color: var(--primary); background: #eff6ff; color: var(--primary); }
.pv-drop-zone .icon { font-size: 2rem; margin-bottom: .5rem; }
.pv-file-chosen { font-size: .82rem; color: var(--primary); font-weight: 600; margin-top: .4rem; }
/* Form */
.form-group { display: flex; flex-direction: column; gap: .35rem; }
.form-label { font-size: .8rem; 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; font-family: inherit; }
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: .4rem; padding: .4rem .85rem; 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: .25rem .55rem; font-size: .78rem; }
.btn-ghost { background: transparent; border: none; color: var(--text-muted); padding: .2rem .4rem; }
.btn-ghost:hover { color: var(--text-main); background: var(--bg-page); border-radius: 6px; }
.btn-icon { padding: .3rem; min-width: 30px; justify-content: center; }
/* Detail panel */
.pv-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem .75rem; }
.pv-detail-item { display: flex; flex-direction: column; gap: .1rem; }
.pv-detail-label { font-size: .72rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; }
.pv-detail-value { font-size: .875rem; color: var(--text-main); }
.pv-detail-thumb { width: 100%; aspect-ratio: 4/3; background: #f1f5f9; border-radius: 10px; overflow: hidden; display: flex; align-items: center; justify-content: center; font-size: 4rem; margin-bottom: .75rem; }
.pv-detail-thumb img { width: 100%; height: 100%; object-fit: cover; }
/* Toasts */
.pv-toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; display: flex; flex-direction: column; gap: .5rem; }
.pv-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: pvToastIn .2s ease; }
.pv-toast.error { border-left-color: var(--danger); }
@keyframes pvToastIn { from{opacity:0;transform:translateX(16px)} to{opacity:1;transform:none} }
/* Upload progress */
.pv-progress { height: 4px; background: var(--border-light); border-radius: 999px; overflow: hidden; margin-top: .4rem; }
.pv-progress-bar { height: 100%; background: var(--primary); border-radius: 999px; transition: width .3s; }
/* Responsive */
@media (max-width: 768px) {
.pv-layout { grid-template-columns: 1fr; height: auto; }
.pv-sidebar { display: none; }
.pv-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); padding: .75rem; }
.pv-main-header { padding: .75rem 1rem; }
}
/* Dark mode */
[data-theme="dark"] .pv-sidebar { background: var(--bg-panel); }
[data-theme="dark"] .pv-card { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .pv-card-thumb { background: #1c2128; }
[data-theme="dark"] .pv-modal { background: var(--bg-panel); }
[data-theme="dark"] .form-control { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
[data-theme="dark"] .pv-drop-zone { border-color: var(--border-light); }
[data-theme="dark"] .pv-drop-zone:hover { background: rgba(59,130,246,.1); }
[data-theme="dark"] .pv-main-header { background: var(--bg-panel); }
[data-theme="dark"] .pv-type-stl { background: rgba(139,92,246,.2); }
[data-theme="dark"] .pv-type-3mf { background: rgba(59,130,246,.2); }
[data-theme="dark"] .pv-type-gcode, [data-theme="dark"] .pv-type-g, [data-theme="dark"] .pv-type-gco { background: rgba(16,185,129,.2); }
+195
View File
@@ -0,0 +1,195 @@
// HouseHub — PrintVault module (local storage)
const API = '/modules/printvault/api.php';
function escHtml(s) { const d = document.createElement('div'); d.textContent = String(s ?? ''); return d.innerHTML; }
function fmtSize(b) { if (!b) return '—'; return b > 1048576 ? (b/1048576).toFixed(1)+' Mo' : Math.round(b/1024)+' Ko'; }
function fmtDate(s) { if (!s) return '—'; return new Date(s).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}); }
function toast(msg, type='success') {
const c = document.getElementById('pv-toasts');
const t = document.createElement('div');
t.className = 'pv-toast' + (type==='error' ? ' error' : '');
t.textContent = msg; c.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
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 && !(data instanceof FormData)) { opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(data); }
else if (data instanceof FormData) opts.body = 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:', text.slice(0,300)); throw new Error('Erreur serveur'); }
if (!j.ok) throw new Error(j.error || 'Erreur');
return j.data;
}
// ─── State ────────────────────────────────────────────────────────────────────
let currentCategory = null;
let currentType = null;
let searchQuery = '';
let allModels = [];
let categories = [];
// ─── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadAll();
document.getElementById('pv-search')?.addEventListener('input', e => { searchQuery = e.target.value.trim(); renderModels(); });
document.querySelectorAll('.pv-modal-backdrop').forEach(m => m.addEventListener('click', e => { if (e.target===m) m.classList.remove('show'); }));
const dz = document.getElementById('pv-drop-zone');
if (dz) {
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('over'); if (e.dataTransfer.files[0]) setUploadFile(e.dataTransfer.files[0]); });
dz.addEventListener('click', () => document.getElementById('pv-file-input').click());
document.getElementById('pv-file-input').addEventListener('change', e => { if (e.target.files[0]) setUploadFile(e.target.files[0]); });
}
});
async function loadAll() {
try {
const [cats] = await Promise.all([api('categories')]);
categories = cats;
await loadModels();
} catch(e) { toast(e.message, 'error'); }
}
// ─── Sidebar ──────────────────────────────────────────────────────────────────
function renderSidebar() {
const el = document.getElementById('pv-sidebar-nav');
let html = `
<div class="pv-nav-section">Catégories</div>
<div class="pv-nav-item${currentCategory===null?' active':''}" onclick="setCategory(null)">
🗂 Toutes <span class="pv-nav-badge">${allModels.length}</span>
</div>`;
categories.forEach(c => {
const count = allModels.filter(m => m.category === c.name).length;
html += `<div class="pv-nav-item${currentCategory===c.name?' active':''}" onclick="setCategory('${escHtml(c.name).replace(/'/g,"\\'")}')">
<span class="pv-cat-dot" style="background:${escHtml(c.color)}"></span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(c.name)}</span>
<span class="pv-nav-badge">${count}</span>
</div>`;
});
el.innerHTML = html;
}
function setCategory(cat) { currentCategory = cat; renderSidebar(); renderModels(); }
function setType(type) {
currentType = currentType === type ? null : type;
document.querySelectorAll('.pv-chip').forEach(c => c.classList.toggle('active', c.dataset.type === currentType));
renderModels();
}
// ─── Models ───────────────────────────────────────────────────────────────────
async function loadModels() {
try {
allModels = await api('models');
renderSidebar();
renderModels();
} catch(e) { toast(e.message, 'error'); }
}
function filterModels() {
return allModels.filter(m => {
if (currentCategory && m.category !== currentCategory) return false;
if (currentType) {
const ext = m.file_type.toLowerCase();
if (currentType === 'gcode' && !['gcode','gco','g'].includes(ext)) return false;
if (currentType !== 'gcode' && ext !== currentType) return false;
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
if (!m.name.toLowerCase().includes(q) && !(m.description||'').toLowerCase().includes(q) && !(m.tags||'').toLowerCase().includes(q)) return false;
}
return true;
});
}
function renderModels() {
const el = document.getElementById('pv-grid');
const filtered = filterModels();
document.getElementById('pv-subtitle').textContent = filtered.length + ' modèle' + (filtered.length > 1 ? 's' : '');
if (!filtered.length) {
el.innerHTML = `<div class="pv-empty" style="grid-column:1/-1"><div class="icon">🖨️</div><p>${allModels.length ? 'Aucun modèle dans ce filtre.' : 'Aucun modèle. Uploadez votre premier fichier !'}</p></div>`;
return;
}
el.innerHTML = filtered.map(m => cardHtml(m)).join('');
}
const typeIcon = {stl:'🟣','3mf':'🔵',gcode:'🟢',g:'🟢',gco:'🟢'};
function cardHtml(m) {
const ext = m.file_type.toLowerCase();
const icon = typeIcon[ext] || '📄';
const thumbUrl = m.thumb ? `/uploads/printvault/thumbs/${encodeURIComponent(m.thumb)}` : null;
const thumbHtml = thumbUrl
? `<img src="${escHtml(thumbUrl)}" alt="" loading="lazy" onerror="this.parentNode.innerHTML='${icon}'">`
: icon;
const dims = m.dim_x > 0 ? `${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)}mm` : '';
const gcInfo = m.gcode_time || '';
return `<a class="pv-card" href="/printvault-viewer.php?id=${m.id}" style="text-decoration:none">
<div class="pv-card-thumb">${thumbHtml}</div>
<div class="pv-card-body">
<div class="pv-card-name">${escHtml(m.name)}</div>
<div class="pv-card-meta">
<span class="pv-type-badge pv-type-${escHtml(ext)}">${escHtml(m.file_type.toUpperCase())}</span>
${dims ? `<span>${escHtml(dims)}</span>` : ''}
${gcInfo ? `<span>⏱ ${escHtml(gcInfo)}</span>` : ''}
<span style="margin-left:auto">${escHtml(fmtSize(m.file_size))}</span>
</div>
</div>
</a>`;
}
// ─── Upload ───────────────────────────────────────────────────────────────────
let uploadFile = null;
function openUpload() {
uploadFile = null;
document.getElementById('pv-file-input').value = '';
document.getElementById('pv-upload-filename').textContent = '';
document.getElementById('pv-upload-name').value = '';
document.getElementById('pv-upload-desc').value = '';
document.getElementById('pv-upload-tags').value = '';
const sel = document.getElementById('pv-upload-cat');
sel.innerHTML = categories.map(c => `<option value="${escHtml(c.name)}">${escHtml(c.name)}</option>`).join('');
document.getElementById('pv-upload-progress').style.display = 'none';
document.getElementById('pv-upload-modal').classList.add('show');
}
function setUploadFile(f) {
const ext = f.name.split('.').pop().toLowerCase();
if (!['stl','3mf','gcode','gco','g'].includes(ext)) { toast('Format non supporté', 'error'); return; }
uploadFile = f;
document.getElementById('pv-upload-filename').textContent = f.name;
if (!document.getElementById('pv-upload-name').value)
document.getElementById('pv-upload-name').value = f.name.replace(/\.[^.]+$/,'').replace(/[_-]/g,' ');
}
async function saveUpload() {
if (!uploadFile) { toast('Choisissez un fichier', 'error'); return; }
const name = document.getElementById('pv-upload-name').value.trim();
if (!name) { toast('Nom requis', 'error'); return; }
const fd = new FormData();
fd.append('file', uploadFile);
fd.append('name', name);
fd.append('description', document.getElementById('pv-upload-desc').value);
fd.append('category', document.getElementById('pv-upload-cat').value);
fd.append('tags', document.getElementById('pv-upload-tags').value);
const prog = document.getElementById('pv-upload-progress');
const bar = document.getElementById('pv-upload-bar');
prog.style.display = ''; bar.style.width = '20%';
try {
await api('models', 'POST', fd);
bar.style.width = '100%';
setTimeout(() => {
document.getElementById('pv-upload-modal').classList.remove('show');
toast('Modèle ajouté ✓');
loadModels();
}, 300);
} catch(e) { prog.style.display = 'none'; toast(e.message, 'error'); }
}
+175
View File
@@ -0,0 +1,175 @@
/* PrintVault — detail/viewer page */
.pv-detail-page {
display: flex;
flex-direction: column;
height: calc(100vh - 64px);
overflow: hidden;
background: var(--bg-page);
}
/* Top bar */
.pv-detail-topbar {
display: flex;
align-items: center;
gap: .75rem;
padding: .75rem 1.25rem;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
flex-wrap: wrap;
}
.pv-detail-topbar-title {
display: flex;
align-items: center;
gap: .6rem;
min-width: 0;
}
.pv-detail-topbar-title h1 {
font-size: 1rem;
font-weight: 700;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Body: viewer + sidebar */
.pv-detail-body {
display: grid;
grid-template-columns: 1fr 320px;
flex: 1;
overflow: hidden;
}
/* Viewer area */
.pv-detail-left {
overflow: hidden;
position: relative;
background: #0f172a;
}
.pv-viewer-wrap {
width: 100%;
height: 100%;
position: relative;
}
.pv-viewer-wrap canvas { display: block; width: 100% !important; height: 100% !important; }
.pv-viewer-loading {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 1rem; background: #0f172a; z-index: 10; color: #94a3b8; font-size: .9rem;
}
.pv-spinner {
width: 36px; height: 36px;
border: 3px solid #1e293b; border-top-color: #3b82f6;
border-radius: 50%; animation: pvSpin .8s linear infinite;
}
@keyframes pvSpin { to { transform: rotate(360deg); } }
.pv-viewer-controls-hint {
position: absolute; bottom: 1rem; left: 50%; transform: translateX(-50%);
font-size: .72rem; color: #475569; background: rgba(15,23,42,.7);
padding: .3rem .7rem; border-radius: 999px; pointer-events: none;
white-space: nowrap;
}
.pv-viewer-screenshot-btn {
position: absolute; top: .75rem; right: .75rem;
background: rgba(15,23,42,.8); border: 1px solid rgba(255,255,255,.15);
color: #e2e8f0; border-radius: 8px; padding: .35rem .6rem;
cursor: pointer; font-size: .85rem; transition: background .15s;
}
.pv-viewer-screenshot-btn:hover { background: rgba(59,130,246,.4); }
/* Thumbnail fallback */
.pv-detail-thumb-large {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-size: 8rem; background: #1e293b;
}
.pv-detail-thumb-large img { width: 100%; height: 100%; object-fit: contain; }
.pv-detail-thumb-placeholder { color: #334155; }
/* Right sidebar metadata */
.pv-detail-right {
overflow-y: auto;
background: var(--bg-panel);
border-left: 1px solid var(--border-light);
display: flex;
flex-direction: column;
gap: 0;
}
.pv-meta-section {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light);
}
.pv-meta-section:last-child { border-bottom: none; }
.pv-meta-title {
font-size: .72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: var(--text-muted); margin-bottom: .75rem;
}
.pv-meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
.pv-meta-item { display: flex; flex-direction: column; gap: .15rem; }
.pv-meta-item--full { grid-column: 1 / -1; }
.pv-meta-label { font-size: .72rem; color: var(--text-muted); font-weight: 500; }
.pv-meta-value { font-size: .875rem; color: var(--text-main); font-weight: 500; }
/* Description & tags */
.pv-detail-description { font-size: .875rem; color: var(--text-muted); line-height: 1.5; }
.pv-tags-row { display: flex; gap: .35rem; flex-wrap: wrap; }
.pv-tag {
font-size: .72rem; background: var(--bg-page); border: 1px solid var(--border-light);
padding: .15rem .5rem; border-radius: 999px; color: var(--text-muted);
}
/* GCode metadata cards */
.pv-gcode-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .6rem;
}
.pv-gcode-card {
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: .85rem .9rem;
display: flex; flex-direction: column; gap: .2rem;
transition: border-color .15s;
}
.pv-gcode-card:hover { border-color: #cbd5e1; }
.pv-gcode-icon { font-size: 1.3rem; margin-bottom: .1rem; }
.pv-gcode-value {
font-size: 1.05rem; font-weight: 700; color: var(--text-main);
line-height: 1.2;
}
.pv-gcode-label {
font-size: .72rem; color: var(--text-muted); line-height: 1.3;
}
/* GCode legend */
.pv-gcode-viewer-legend {
position: absolute; bottom: 2.5rem; left: 1rem;
background: rgba(15,23,42,.8); backdrop-filter: blur(4px);
border: 1px solid rgba(255,255,255,.1);
border-radius: 8px; padding: .5rem .75rem;
display: flex; flex-direction: column; gap: .3rem;
pointer-events: none;
}
.pv-legend-item {
font-size: .72rem; color: #94a3b8;
display: flex; align-items: center; gap: .5rem;
}
/* Responsive */
@media (max-width: 900px) {
.pv-detail-body { grid-template-columns: 1fr; grid-template-rows: 55vh 1fr; }
.pv-detail-right { border-left: none; border-top: 1px solid var(--border-light); }
.pv-detail-page { height: auto; overflow: auto; }
.pv-detail-body { overflow: visible; }
}
/* Dark mode */
[data-theme="dark"] .pv-detail-topbar { background: var(--bg-panel); }
[data-theme="dark"] .pv-detail-right { background: var(--bg-panel); }
[data-theme="dark"] .pv-gcode-card { background: var(--bg-page); }
[data-theme="dark"] .pv-detail-thumb-large { background: #0d1117; }

Some files were not shown because too many files have changed in this diff Show More