24 Commits
Author SHA1 Message Date
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
31 changed files with 4477 additions and 1070 deletions
+19 -4
View File
@@ -2,7 +2,7 @@ name: Deploy HouseHub
on: on:
push: push:
branches: [main] branches: [main, staging-perco, staging-fefe]
jobs: jobs:
deploy: deploy:
@@ -15,6 +15,21 @@ jobs:
username: perco username: perco
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
script: | script: |
cd /opt/container/househub BRANCH="${{ github.ref_name }}"
git pull case "$BRANCH" in
echo "✓ HouseHub mis à jour" main)
cd /opt/container/househub
git pull
echo "✓ Production mise à jour"
;;
staging-perco)
cd /opt/container/househub-perco
git pull origin staging-perco
echo "✓ Staging Perco mis à jour"
;;
staging-fefe)
cd /opt/container/househub-fefe
git pull origin staging-fefe
echo "✓ Staging Fefe mis à jour"
;;
esac
+1
View File
@@ -1,3 +1,4 @@
househub_db_data househub_db_data
.env .env
data/.hh_app_secret data/.hh_app_secret
extract_source.php
+19 -13
View File
@@ -151,12 +151,15 @@ L'architecture de HouseHub isole chaque foyer dans sa propre base de données (`
Afin de transformer complètement l'application héritée de Pacha Family en un produit générique et autonome pour chaque espace familial, les chantiers suivants doivent être menés. Afin de transformer complètement l'application héritée de Pacha Family en un produit générique et autonome pour chaque espace familial, les chantiers suivants doivent être menés.
🟢 Niveau 1 : Facile (Constantes et configurations globales) 🟢 Niveau 1 : Facile (Constantes et configurations globales)
[x] Configuration de base : Extraire la devise (CURRENCY) et la zone de vacances scolaires (ZONE_SCOLAIRE) du fichier config.php pour les stocker dans les paramètres d'espace en BDD. [x] Configuration de base : Extraire la devise (CURRENCY) et la zone de vacances scolaires (ZONE_SCOLAIRE) du fichier config.php pour les stocker dans les paramètres d'espace en BDD.
[x] Suppression des IDs statiques : Supprimer les IDs parents fixes (ID_ALEX = 2, ID_LAIA = 3) dans config.php au profit d'une lecture dynamique basée sur les utilisateurs réels de la famille connectée (liens via la table pf_people).
[x] Charte graphique dynamique : Remplacer les couleurs codées en dur (bleu/orange) par des sélecteurs natifs dans l'espace "Paramètres", en générant les variables CSS et leurs nuances (via color-mix()) à la volée.
[ ] Filtres API Éducation Nationale : Rendre dynamique la zone de l'API (zones LIKE '%Zone C%' dans family-calendar.js) pour s'adapter à la région de chaque foyer. [ ] Filtres API Éducation Nationale : Rendre dynamique la zone de l'API (zones LIKE '%Zone C%' dans family-calendar.js) pour s'adapter à la région de chaque foyer.
[ ] Identifiant Planka : Variabiliser le project_id Planka par défaut écrit en dur dans planka/api.php. [ ] Identifiant Planka : Variabiliser le project_id Planka par défaut écrit en dur dans planka/api.php.
[ ] Suppression des IDs statiques : Supprimer les IDs parents fixes (ID_ALEX = 2, ID_LAIA = 3) dans config.php au profit d'une lecture dynamique basée sur les utilisateurs réels de la famille connectée.
🟡 Niveau 2 : Intermédiaire (Listes et dictionnaires en dur) 🟡 Niveau 2 : Intermédiaire (Listes et dictionnaires en dur)
[ ] Gestion des membres (Cadeaux) : Dans gift-list.php, remplacer la liste d'enfants ($children) et d'adultes ($baseAdults, $extraAdults) codée en dur par une lecture dynamique des membres du foyer déclarés en BDD. [ ] Gestion des membres (Cadeaux) : Dans gift-list.php, remplacer la liste d'enfants ($children) et d'adultes ($baseAdults, $extraAdults) codée en dur par une lecture dynamique des membres du foyer déclarés en BDD.
[ ] Occasions festives (Cadeaux) : Rendre configurables les occasions spéciales (TIO, NOEL, ROIS, ANNIV, SANT). [ ] Occasions festives (Cadeaux) : Rendre configurables les occasions spéciales (TIO, NOEL, ROIS, ANNIV, SANT).
[ ] Configuration des catégories (Budget) : Dans budget/views/suivi.php, migrer le tableau $categoriesConfig (FMCG, Essence, École, etc.), leurs couleurs et leurs suggestions de magasins associés vers un stockage paramétrable. [ ] Configuration des catégories (Budget) : Dans budget/views/suivi.php, migrer le tableau $categoriesConfig (FMCG, Essence, École, etc.), leurs couleurs et leurs suggestions de magasins associés vers un stockage paramétrable.
@@ -164,7 +167,9 @@ Afin de transformer complètement l'application héritée de Pacha Family en un
[ ] Types de carburants (Garage) : Variabiliser la liste statique des carburants dans garage.php. [ ] Types de carburants (Garage) : Variabiliser la liste statique des carburants dans garage.php.
🔴 Niveau 3 : Complexe (Logique métier et Schéma SQL) 🔴 Niveau 3 : Complexe (Logique métier et Schéma SQL)
[ ] Refonte relationnelle de la répartition (Budget) : Modifier la structure de la table pf_alloc_values pour supprimer les colonnes figées amount_alex et amount_laia et basculer sur un modèle relationnel (alloc_id, user_id, amount) capable de gérer n'importe quel nombre d'adultes (1, 2, 3 ou plus).
[x] Refonte relationnelle du Budget (Prévisionnel) : Refonte totale de budget_prev.php. Modification de la table pf_alloc_values pour basculer sur des colonnes génériques (amount_p1, amount_p2) et suppression définitive de toute logique conditionnelle liée aux prénoms des concepteurs originaux.
[ ] Refonte globale de la répartition (Budget) : Étendre le modèle relationnel (alloc_id, user_id, amount) au reste de l'application pour gérer n'importe quel nombre d'adultes (1, 2, 3 ou plus).
[ ] Destinations de transferts (Budget) : Rendre dynamiques les cibles de virements prévisionnels (vers L.Pol, vers L.Pep, etc.) en fonction des comptes épargne créés par la famille. [ ] Destinations de transferts (Budget) : Rendre dynamiques les cibles de virements prévisionnels (vers L.Pol, vers L.Pep, etc.) en fonction des comptes épargne créés par la famille.
[ ] Onglets d'Épargne : Générer dynamiquement les sous-onglets de la vue epargne.php à partir des membres réels au lieu des blocs statiques Alex, Laia et Nens. [ ] Onglets d'Épargne : Générer dynamiquement les sous-onglets de la vue epargne.php à partir des membres réels au lieu des blocs statiques Alex, Laia et Nens.
[ ] Types de Garde (Calendrier) : Supprimer les types d'événements et compteurs codés en dur (OFF_CAROLE, EXTRA_OFF_CAROLE, CENTRE, AVIS, PEP_SICK) pour permettre à chaque foyer de définir ses propres modalités de garde et compteurs de congés associés. [ ] Types de Garde (Calendrier) : Supprimer les types d'événements et compteurs codés en dur (OFF_CAROLE, EXTRA_OFF_CAROLE, CENTRE, AVIS, PEP_SICK) pour permettre à chaque foyer de définir ses propres modalités de garde et compteurs de congés associés.
@@ -174,14 +179,15 @@ Afin de transformer complètement l'application héritée de Pacha Family en un
## 📌 Versioning ## 📌 Versioning
| Version | Changements | | Version | Changements |
| ------- | ------------------------------------------------------------------------------------------ | | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| v2.2.0 | Multi-tenant (p.1) : Paramètres dynamiques du foyer (€, Zone) via BDD + script migration | | v2.3.0 | Multi-tenant (p.2) : Refonte BDD Budget (p1, p2), suppression IDs statiques et thèmes de profils dynamiques (color-mix()) |
| v2.1.0 | Module Planka : Kanban natif connecté à Planka via API proxy PHP | | v2.2.0 | Multi-tenant (p.1) : Paramètres dynamiques du foyer (€, Zone) via BDD + script migration |
| v2.0.0 | Module PrintVault : viewer 3D STL/3MF/GCode, stockage local, trajectoires GCode illimitées | | v2.1.0 | Module Planka : Kanban natif connecté à Planka via API proxy PHP |
| v1.5.0 | Modules Todo (rappels Discord), Mémo, Courses (liste partagée) | | v2.0.0 | Module PrintVault : viewer 3D STL/3MF/GCode, stockage local, trajectoires GCode illimitées |
| v1.4.0 | Synchronisation CalDAV iOS | | v1.5.0 | Modules Todo (rappels Discord), Mémo, Courses (liste partagée) |
| v1.3.0 | Optimisation SQL — suppression des requêtes N+1, temps de réponse ÷3 | | v1.4.0 | Synchronisation CalDAV iOS |
| v1.2.0 | Modernisation UI — Toasts, confirmations sur-mesure | | v1.3.0 | Optimisation SQL — suppression des requêtes N+1, temps de réponse ÷3 |
| v1.1.0 | Refonte expérience mobile (Bottom Sheets) | | v1.2.0 | Modernisation UI — Toasts, confirmations sur-mesure |
| v1.0.0 | Version stable initiale — Budget, Calendrier, Voyages, Cadeaux, Garage | | v1.1.0 | Refonte expérience mobile (Bottom Sheets) |
| v1.0.0 | Version stable initiale — Budget, Calendrier, Voyages, Cadeaux, Garage |
+2 -1
View File
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS user_calendar_integrations (
calendar_url VARCHAR(1024) DEFAULT NULL, calendar_url VARCHAR(1024) DEFAULT NULL,
status VARCHAR(30) DEFAULT 'connected', status VARCHAR(30) DEFAULT 'connected',
last_sync_at DATETIME DEFAULT NULL, last_sync_at DATETIME DEFAULT NULL,
calendar_prefs_json TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_provider (user_id, provider), UNIQUE KEY uq_user_provider (user_id, provider),
@@ -44,4 +45,4 @@ CREATE TABLE IF NOT EXISTS user_calendar_integrations (
-- Donner au user applicatif le droit de créer des DBs famille -- Donner au user applicatif le droit de créer des DBs famille
GRANT ALL PRIVILEGES ON `househub_f%`.* TO 'househub'@'%'; GRANT ALL PRIVILEGES ON `househub_f%`.* TO 'househub'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
+74 -65
View File
@@ -1,18 +1,8 @@
-- HouseHub — Schéma MySQL -- HouseHub — Schéma MySQL (Modèle pour une base famille)
-- Créer la base : CREATE DATABASE househub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4; SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0; SET FOREIGN_KEY_CHECKS = 0;
-- ─── Utilisateurs ───────────────────────────────────────────────────────────── -- ─── Configuration du foyer ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Familles ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_foyer_settings ( CREATE TABLE IF NOT EXISTS pf_foyer_settings (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT '', currency VARCHAR(10) NOT NULL DEFAULT '',
@@ -20,13 +10,24 @@ CREATE TABLE IF NOT EXISTS pf_foyer_settings (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Initialisation de la ligne unique par défaut
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '', 'C'); INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '', 'C');
-- ─── Utilisateurs (Legacy) ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Personnes ──────────────────────────────────────────────────────────────── -- ─── Personnes ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_people ( CREATE TABLE `pf_people` (
id INT AUTO_INCREMENT PRIMARY KEY, `id` int(11) NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL `name` varchar(100) NOT NULL,
`user_id` int(11) DEFAULT NULL,
`role` varchar(50) DEFAULT NULL,
`color` varchar(7) DEFAULT '#0891b2',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Calendrier familial ────────────────────────────────────────────────────── -- ─── Calendrier familial ──────────────────────────────────────────────────────
@@ -112,20 +113,22 @@ CREATE TABLE IF NOT EXISTS pf_expenses (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_alloc_categories ( CREATE TABLE IF NOT EXISTS pf_alloc_categories (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
target DECIMAL(10,2) DEFAULT 0, target DECIMAL(10,2) DEFAULT 0.00,
holiday_id INT DEFAULT NULL, transfer_dest VARCHAR(50) DEFAULT NULL,
sort_order INT DEFAULT 0 holiday_id INT DEFAULT NULL,
sort_order INT DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_alloc_values ( CREATE TABLE IF NOT EXISTS pf_alloc_values (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
month_date VARCHAR(10) NOT NULL, month_date VARCHAR(10) NOT NULL,
cat_id INT NOT NULL, cat_id INT NOT NULL,
amount_alex DECIMAL(10,2) DEFAULT 0, person_id INT NOT NULL,
amount_laia DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0.00,
UNIQUE KEY uq_alloc (month_date, cat_id) UNIQUE KEY uq_alloc_person (month_date, cat_id, person_id),
FOREIGN KEY (person_id) REFERENCES pf_people(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_savings ( CREATE TABLE IF NOT EXISTS pf_savings (
@@ -158,9 +161,22 @@ CREATE TABLE IF NOT EXISTS pf_salary_config (
CREATE TABLE IF NOT EXISTS pf_import_rules ( CREATE TABLE IF NOT EXISTS pf_import_rules (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL UNIQUE, keyword VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL category VARCHAR(100) NOT NULL,
budget_item_id INT(11) NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_advances (
id int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
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()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_notes ( CREATE TABLE IF NOT EXISTS pf_notes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
note_type VARCHAR(100) NOT NULL, note_type VARCHAR(100) NOT NULL,
@@ -169,6 +185,29 @@ CREATE TABLE IF NOT EXISTS pf_notes (
UNIQUE KEY uq_note (note_type, reference_id) UNIQUE KEY uq_note (note_type, reference_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content LONGTEXT DEFAULT '',
tags VARCHAR(1000) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FULLTEXT KEY ft_notes (title, content, tags)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
note_id INT NOT NULL,
type ENUM('image','file','url') NOT NULL DEFAULT 'file',
filename VARCHAR(255) DEFAULT NULL,
original_name VARCHAR(255) DEFAULT NULL,
url TEXT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Voyages / Holidays ─────────────────────────────────────────────────────── -- ─── Voyages / Holidays ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_holidays ( CREATE TABLE IF NOT EXISTS pf_holidays (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -236,17 +275,6 @@ CREATE TABLE IF NOT EXISTS pf_gifts (
amount DECIMAL(8,2) DEFAULT 0 amount DECIMAL(8,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;
-- ─── 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');
-- 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');
-- ─── Garage Manager ─────────────────────────────────────────────────────────── -- ─── Garage Manager ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_vehicles ( CREATE TABLE IF NOT EXISTS pf_vehicles (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -321,30 +349,6 @@ CREATE TABLE IF NOT EXISTS pf_garage_documents (
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_memo_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content LONGTEXT DEFAULT '',
tags VARCHAR(1000) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FULLTEXT KEY ft_notes (title, content, tags)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
note_id INT NOT NULL,
type ENUM('image','file','url') NOT NULL DEFAULT 'file',
filename VARCHAR(255) DEFAULT NULL,
original_name VARCHAR(255) DEFAULT NULL,
url TEXT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Todo ───────────────────────────────────────────────────────────────────── -- ─── Todo ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_todo_lists ( CREATE TABLE IF NOT EXISTS pf_todo_lists (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -372,15 +376,17 @@ CREATE TABLE IF NOT EXISTS pf_todos (
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Module Liste (multi-listes) ────────────────────────────────────────────── -- ─── Module Liste (Courses partagées) ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_lists ( CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste', name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
position INT NOT NULL DEFAULT 0, color VARCHAR(20) DEFAULT NULL,
list_type VARCHAR(50) DEFAULT NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0); INSERT IGNORE INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
CREATE TABLE IF NOT EXISTS pf_grocery_items ( CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -445,6 +451,7 @@ CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
external_uid VARCHAR(255) NOT NULL, external_uid VARCHAR(255) NOT NULL,
external_etag VARCHAR(255) DEFAULT NULL, external_etag VARCHAR(255) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL, calendar_url VARCHAR(1024) DEFAULT NULL,
external_href VARCHAR(2048) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_calendar_event (calendar_event_id), UNIQUE KEY uq_calendar_event (calendar_event_id),
@@ -496,3 +503,5 @@ CREATE TABLE IF NOT EXISTS pf_planka_config (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id) PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;
-105
View File
@@ -1,105 +0,0 @@
<?php
/**
* HouseHub OS - Source Extractor 🦙🚀
* Génère un fichier Markdown contenant tout le code source du projet.
* Idéal pour alimenter une GEM ou un LLM.
*/
// 1. 🚨 SÉCURITÉ : Ne jamais laisser ce fichier accessible publiquement sans protection !
// Appelle le script via : https://househub.nas.../extract_source.php?key=TON_MOT_DE_PASSE
$secretKey = 'mR83s7MmXbP$jer$9C4HnGkry6xhGL6';
if (!isset($_GET['key']) || $_GET['key'] !== $secretKey) {
http_response_code(403);
die("⛔ Accès refusé. Veuillez fournir la clé de sécurité.");
}
// Forcer l'affichage en texte brut dans le navigateur pour un copier-coller facile
header('Content-Type: text/plain; charset=utf-8');
$root = __DIR__;
// 2. CONFIGURATION DU FILTRAGE
// Dossiers à exclure (ne mets pas de slash au début)
$excludeDirs = [
'.git',
'.gitea',
'.devtools',
'vendor',
'node_modules',
'assets/img',
'modules/holidays/assets/img',
'modules/family-calendar/assets/img',
'modules/gift-list/assets/img'
];
// Fichiers spécifiques à exclure (Sécurité & Bruit)
$excludeFiles = [
'.env',
'.env.example',
'extract_source.php', // On s'exclut soi-même
'pachafamily_source.md',
'docker-compose.override.yml'
];
// Extensions autorisées (on ignore les images, pdf, zip, etc.)
$allowedExtensions = ['php', 'js', 'css', 'sql', 'json', 'yml', 'yaml', 'md', 'sh'];
// 3. INITIALISATION DU PARCOURS
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$report = "# 🦙 Source Code HouseHub OS\n\n";
$report .= "> *Généré le " . date('Y-m-d H:i:s') . "*\n\n";
$report .= "---\n\n";
// 4. BOUCLE D'EXTRACTION
foreach ($iterator as $file) {
if ($file->isDir()) continue;
$filePath = $file->getPathname();
$relativePath = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
$relativePath = str_replace('\\', '/', $relativePath); // Uniformisation Windows/Linux
// Extraction de l'extension
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
// A. Filtrage par dossier
$skip = false;
foreach ($excludeDirs as $dir) {
// Si le chemin commence par ce dossier, ou contient /ce_dossier/
if (strpos($relativePath, $dir . '/') === 0 || strpos($relativePath, '/' . $dir . '/') !== false) {
$skip = true;
break;
}
}
if ($skip) continue;
// B. Filtrage par nom de fichier
if (in_array(basename($filePath), $excludeFiles)) continue;
// C. Filtrage par extension
if (!in_array($ext, $allowedExtensions)) continue;
// 5. LECTURE ET FORMATAGE
$content = file_get_contents($filePath);
if ($content === false) {
$content = "// Erreur lors de la lecture de ce fichier.";
}
// Ajustement du tag de langage pour le Markdown
$lang = $ext;
if ($ext === 'js') $lang = 'javascript';
if ($ext === 'yml') $lang = 'yaml';
$report .= "### 📄 Fichier : `$relativePath`\n";
$report .= "```$lang\n";
$report .= trim($content) . "\n";
$report .= "```\n\n---\n\n";
}
// 6. SORTIE
echo $report;
+24 -10
View File
@@ -8,6 +8,12 @@ require_login();
require __DIR__ . '/includes/db.php'; require __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/i18n.php'; require_once __DIR__ . '/includes/i18n.php';
$stmtPeople = $pdo->query("SELECT id, name, user_id, role FROM pf_people ORDER BY id ASC");
$familyPeople = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
$parents = array_values(array_filter($familyPeople, function($p) {
return strtolower($p['role'] ?? '') === 'parent';
}));
$pageTitle = tr('fc_page_title'); $pageTitle = tr('fc_page_title');
$activePage = "family-calendar"; $activePage = "family-calendar";
$mainClass = "pf-family-calendar"; $mainClass = "pf-family-calendar";
@@ -70,8 +76,12 @@ require __DIR__ . '/header.php';
<div class="pf-form-group"> <div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_person') ?></label> <label class="pf-label"><?= tr('fc_label_person') ?></label>
<select id="snapPerson" class="pf-input" required> <select id="snapPerson" class="pf-input" required>
<option value="2">Alex</option> <option value="" disabled selected>-- <?= tr('fc_choose_person') ?> --</option>
<option value="3">Laia</option> <?php foreach ($familyPeople as $person): ?>
<option value="<?= htmlspecialchars($person['id']) ?>">
<?= htmlspecialchars($person['name']) ?>
</option>
<?php endforeach; ?>
</select> </select>
</div> </div>
<div class="pf-form-group"> <div class="pf-form-group">
@@ -169,20 +179,24 @@ require __DIR__ . '/header.php';
<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_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_pep_sick') ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_presence') ?></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-alex header-group"><?= htmlspecialchars(strtoupper($parents[0]['name'] ?? 'PARENT 1')) ?></th>
<th colspan="6" class="col-laia header-group">LAIA</th> <th colspan="6" class="col-laia header-group"><?= htmlspecialchars(strtoupper($parents[1]['name'] ?? 'PARENT 2')) ?></th>
</tr> </tr>
<tr> <tr>
<th colspan="2" class="col-alex-sub">CP</th><th colspan="2" class="col-alex-sub">JRA</th><th colspan="2" class="col-alex-sub">JA</th> <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> <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>
<tr> <tr>
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th> <?php foreach ($parents as $index => $parent):
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th> $parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th> ?>
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th> <th class="<?= $parentClass ?>-sub <?= $parentClass ?>-av"><?= tr('fc_col_av') ?></th>
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th> <th class="<?= $parentClass ?>-sub <?= $parentClass ?>-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="<?= $parentClass ?>-sub <?= $parentClass ?>-av"><?= tr('fc_col_av') ?></th>
<th class="<?= $parentClass ?>-sub <?= $parentClass ?>-use"><?= tr('fc_col_use') ?></th>
<th class="<?= $parentClass ?>-sub <?= $parentClass ?>-av"><?= tr('fc_col_av') ?></th>
<th class="<?= $parentClass ?>-sub <?= $parentClass ?>-use"><?= tr('fc_col_use') ?></th>
<?php endforeach; ?>
</tr> </tr>
</thead> </thead>
<tbody id="planningBody"></tbody> <tbody id="planningBody"></tbody>
+261 -68
View File
@@ -307,7 +307,6 @@ body.pf-home .pf-mobile-close-btn {
.pf-muted-note { .pf-muted-note {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
margin-bottom: 20px;
} }
.pf-muted-note.pf-muted-note--tight { .pf-muted-note.pf-muted-note--tight {
margin-bottom: 8px; margin-bottom: 8px;
@@ -359,6 +358,58 @@ body.pf-home .pf-mobile-close-btn {
gap: 8px; gap: 8px;
} }
/* --- SÉLECTEUR DE COULEUR THÉMATIQUE (SETTINGS) --- */
.pf-color-picker-group {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.pf-input-color {
/* On réduit la taille pour le rendre plus subtil */
width: 36px;
height: 36px;
/* On enlève les marges/paddings par défaut */
padding: 0;
cursor: pointer;
/* On le rend complètement rond, c'est plus moderne */
border-radius: 50%;
border: 2px solid #e2e8f0;
/* On cache le fond par défaut du navigateur */
background: transparent;
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
transition: all 0.2s ease;
/* Optionnel : masquer la bordure interne sur Chrome/Safari */
-webkit-appearance: none;
}
/* On s'assure que le carré de couleur interne (Webkit) remplit tout et est rond */
.pf-input-color::-webkit-color-swatch-wrapper {
padding: 0;
}
.pf-input-color::-webkit-color-swatch {
border: none;
border-radius: 50%;
}
/* Même chose pour Firefox */
.pf-input-color::-moz-color-swatch {
border: none;
border-radius: 50%;
}
.pf-input-color:hover {
border-color: #94a3b8;
transform: scale(1.05);
}
.pf-color-picker-group .pf-muted-note {
font-size: 0.85rem;
color: var(--text-muted, #64748b);
line-height: 1.4;
}
/* Tuile module (checkbox) — état coché via :has(), compatible thème */ /* Tuile module (checkbox) — état coché via :has(), compatible thème */
.pf-module-tile { .pf-module-tile {
display: flex; display: flex;
@@ -968,21 +1019,23 @@ p {
DARK MODE — [data-theme="dark"] sur <html> DARK MODE — [data-theme="dark"] sur <html>
══════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════ */
[data-theme="dark"] { [data-theme="dark"] {
--text-main: #e6edf3; --text-main: #e6edf3;
--text-muted: #8b949e; --text-muted: #8b949e;
--bg-page: #0d1117; --bg-page: #0d1117;
--bg-panel: #161b22; --bg-panel: #161b22;
--border-light: #30363d; --border-light: #30363d;
--shadow: 0 4px 6px -1px rgba(0,0,0,.4), 0 2px 4px -2px rgba(0,0,0,.3); --shadow:
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,.5), 0 4px 6px -4px rgba(0,0,0,.4); 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--bg-subtle: rgba(255,255,255,.06); --shadow-lg:
--bg-soft: rgba(59,130,246,.15); 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--bg-danger-soft: rgba(239,68,68,.1); --bg-subtle: rgba(255, 255, 255, 0.06);
--bg-soft: rgba(59, 130, 246, 0.15);
--bg-danger-soft: rgba(239, 68, 68, 0.1);
--bg-overlay: rgba(0, 0, 0, 0.55); --bg-overlay: rgba(0, 0, 0, 0.55);
--text-danger-soft: #f87171; --text-danger-soft: #f87171;
--border-strong: #484f58; --border-strong: #484f58;
--border-danger-soft: rgba(239,68,68,.3); --border-danger-soft: rgba(239, 68, 68, 0.3);
--focus-ring: rgba(59,130,246,.25); --focus-ring: rgba(59, 130, 246, 0.25);
--alert-success-bg: rgba(34, 197, 94, 0.12); --alert-success-bg: rgba(34, 197, 94, 0.12);
--alert-success-border: rgba(34, 197, 94, 0.45); --alert-success-border: rgba(34, 197, 94, 0.45);
--alert-success-text: #86efac; --alert-success-text: #86efac;
@@ -993,67 +1046,207 @@ p {
--badge-admin-bg: rgba(59, 130, 246, 0.2); --badge-admin-bg: rgba(59, 130, 246, 0.2);
--badge-admin-text: #93c5fd; --badge-admin-text: #93c5fd;
} }
[data-theme="dark"] body { background: var(--bg-page); color: var(--text-main); } [data-theme="dark"] body {
[data-theme="dark"] .pf-header { background: var(--bg-panel); border-color: var(--border-light); } background: var(--bg-page);
[data-theme="dark"] .pf-logo { color: var(--primary); } color: var(--text-main);
[data-theme="dark"] .pf-nav-link { color: var(--text-muted); } }
[data-theme="dark"] .pf-nav-link:hover { background: rgba(255,255,255,.06); color: var(--text-main); } [data-theme="dark"] .pf-header {
[data-theme="dark"] .pf-nav-link--active { background: rgba(59,130,246,.15); color: var(--primary); } background: var(--bg-panel);
[data-theme="dark"] .pf-user-badge { color: var(--text-muted); } border-color: var(--border-light);
[data-theme="dark"] .pf-logout-btn { color: #f87171; } }
[data-theme="dark"] .pf-logout-btn:hover { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.3); } [data-theme="dark"] .pf-logo {
[data-theme="dark"] .pf-burger-btn { color: var(--text-main); } color: var(--primary);
[data-theme="dark"] .pf-mobile-menu { background: var(--bg-panel); color: var(--text-main); } }
[data-theme="dark"] .pf-mobile-menu-header { background: var(--bg-page); border-color: var(--border-light); } [data-theme="dark"] .pf-nav-link {
[data-theme="dark"] .pf-mobile-nav-link { color: var(--text-main); border-color: var(--border-light); } color: var(--text-muted);
[data-theme="dark"] .pf-mobile-nav-link:active { background: rgba(255,255,255,.06); } }
[data-theme="dark"] .pf-module-card { background: var(--bg-panel); border-color: var(--border-light); } [data-theme="dark"] .pf-nav-link:hover {
[data-theme="dark"] .pf-module-card:hover { border-color: #484f58; box-shadow: var(--shadow); } background: rgba(255, 255, 255, 0.06);
[data-theme="dark"] .pf-card-title { color: var(--text-main); } color: var(--text-main);
[data-theme="dark"] .pf-card-desc { color: var(--text-muted); } }
[data-theme="dark"] h1, [data-theme="dark"] h2, [data-theme="dark"] h3 { color: var(--text-main); } [data-theme="dark"] .pf-nav-link--active {
[data-theme="dark"] p { color: var(--text-muted); } background: rgba(59, 130, 246, 0.15);
[data-theme="dark"] .pf-btn.btn-secondary { background: var(--bg-panel); color: var(--text-muted); border-color: var(--border-light); } color: var(--primary);
[data-theme="dark"] .pf-btn.btn-secondary:hover { background: rgba(255,255,255,.05); color: var(--text-main); border-color: #484f58; } }
[data-theme="dark"] .pf-input { background: #1c2128; border-color: var(--border-light); color: var(--text-main); } [data-theme="dark"] .pf-user-badge {
[data-theme="dark"] .pf-input::placeholder { color: #484f58; } color: var(--text-muted);
[data-theme="dark"] .pf-input:focus { background: #1c2128; border-color: var(--primary); } }
[data-theme="dark"] .pf-table { background: var(--bg-panel); } [data-theme="dark"] .pf-logout-btn {
[data-theme="dark"] .pf-table th { background: #1c2128; color: var(--text-muted); border-color: var(--border-light); } color: #f87171;
[data-theme="dark"] .pf-table td { border-color: rgba(48,54,61,.5); color: var(--text-main); } }
[data-theme="dark"] .pf-table tbody tr:hover { background: rgba(255,255,255,.03); } [data-theme="dark"] .pf-logout-btn:hover {
[data-theme="dark"] .pf-modal-content { background: var(--bg-panel); border: 1px solid var(--border-light); } background: rgba(239, 68, 68, 0.1);
[data-theme="dark"] .pf-modal-title { border-color: var(--border-light); color: var(--text-main); } border-color: rgba(239, 68, 68, 0.3);
[data-theme="dark"] .modal-footer { border-color: var(--border-light); } }
[data-theme="dark"] .pf-footer { background: var(--bg-panel); border-color: var(--border-light); } [data-theme="dark"] .pf-burger-btn {
[data-theme="dark"] .pf-toast { background: var(--bg-panel); color: var(--text-main); box-shadow: 0 10px 25px rgba(0,0,0,.4); } color: var(--text-main);
[data-theme="dark"] .pf-login-card { background: var(--bg-panel); border-color: var(--border-light); } }
[data-theme="dark"] .pf-login-error { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.3); } [data-theme="dark"] .pf-mobile-menu {
[data-theme="dark"] .btn-icon-action.edit:hover { background: rgba(59,130,246,.15); } background: var(--bg-panel);
[data-theme="dark"] .btn-icon-action.delete:hover { background: rgba(239,68,68,.1); } color: var(--text-main);
}
[data-theme="dark"] .pf-mobile-menu-header {
background: var(--bg-page);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-mobile-nav-link {
color: var(--text-main);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-mobile-nav-link:active {
background: rgba(255, 255, 255, 0.06);
}
[data-theme="dark"] .pf-module-card {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-module-card:hover {
border-color: #484f58;
box-shadow: var(--shadow);
}
[data-theme="dark"] .pf-card-title {
color: var(--text-main);
}
[data-theme="dark"] .pf-card-desc {
color: var(--text-muted);
}
[data-theme="dark"] h1,
[data-theme="dark"] h2,
[data-theme="dark"] h3 {
color: var(--text-main);
}
[data-theme="dark"] p {
color: var(--text-muted);
}
[data-theme="dark"] .pf-btn.btn-secondary {
background: var(--bg-panel);
color: var(--text-muted);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-btn.btn-secondary:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
border-color: #484f58;
}
[data-theme="dark"] .pf-input {
background: #1c2128;
border-color: var(--border-light);
color: var(--text-main);
}
[data-theme="dark"] .pf-input::placeholder {
color: #484f58;
}
[data-theme="dark"] .pf-input:focus {
background: #1c2128;
border-color: var(--primary);
}
[data-theme="dark"] .pf-table {
background: var(--bg-panel);
}
[data-theme="dark"] .pf-table th {
background: #1c2128;
color: var(--text-muted);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-table td {
border-color: rgba(48, 54, 61, 0.5);
color: var(--text-main);
}
[data-theme="dark"] .pf-table tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
[data-theme="dark"] .pf-modal-content {
background: var(--bg-panel);
border: 1px solid var(--border-light);
}
[data-theme="dark"] .pf-modal-title {
border-color: var(--border-light);
color: var(--text-main);
}
[data-theme="dark"] .modal-footer {
border-color: var(--border-light);
}
[data-theme="dark"] .pf-footer {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-toast {
background: var(--bg-panel);
color: var(--text-main);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] .pf-login-card {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme="dark"] .pf-login-error {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
[data-theme="dark"] .btn-icon-action.edit:hover {
background: rgba(59, 130, 246, 0.15);
}
[data-theme="dark"] .btn-icon-action.delete:hover {
background: rgba(239, 68, 68, 0.1);
}
/* ─── Pill toggle thème ────────────────────────────────────── */ /* ─── Pill toggle thème ────────────────────────────────────── */
.theme-switch { .theme-switch {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex;
background: none; border: none; cursor: pointer; align-items: center;
padding: 3px 5px; border-radius: 8px; transition: background .15s; gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 8px;
transition: background 0.15s;
}
.theme-switch:hover {
background: #f1f5f9;
}
[data-theme="dark"] .theme-switch:hover {
background: rgba(255, 255, 255, 0.08);
}
.ts-sun,
.ts-moon {
font-size: 0.85rem;
line-height: 1;
transition: opacity 0.2s;
user-select: none;
}
[data-theme="dark"] .ts-sun {
opacity: 0.3;
}
.ts-moon {
opacity: 0.3;
}
[data-theme="dark"] .ts-moon {
opacity: 1;
} }
.theme-switch:hover { background: #f1f5f9; }
[data-theme="dark"] .theme-switch:hover { background: rgba(255,255,255,.08); }
.ts-sun, .ts-moon { font-size: .85rem; line-height: 1; transition: opacity .2s; user-select: none; }
[data-theme="dark"] .ts-sun { opacity: .3; }
.ts-moon { opacity: .3; }
[data-theme="dark"] .ts-moon { opacity: 1; }
.ts-track { .ts-track {
position: relative; width: 36px; height: 20px; position: relative;
background: #cbd5e1; border-radius: 999px; width: 36px;
flex-shrink: 0; transition: background .25s; height: 20px;
background: #cbd5e1;
border-radius: 999px;
flex-shrink: 0;
transition: background 0.25s;
}
[data-theme="dark"] .ts-track {
background: #2563eb;
} }
[data-theme="dark"] .ts-track { background: #2563eb; }
.ts-thumb { .ts-thumb {
position: absolute; top: 3px; left: 3px; position: absolute;
width: 14px; height: 14px; border-radius: 50%; top: 3px;
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); left: 3px;
transition: transform .25s cubic-bezier(.4,0,.2,1); width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
[data-theme="dark"] .ts-thumb {
transform: translateX(16px);
} }
[data-theme="dark"] .ts-thumb { transform: translateX(16px); }
+5 -3
View File
@@ -130,10 +130,12 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
*/ */
window.I18N = <?php echo json_encode($current_translations_array ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE); ?>; window.I18N = <?php echo json_encode($current_translations_array ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE); ?>;
// ✅ Injection sécurisée (évite le crash si config.php n'est pas chargé) // ✅ Injection sécurisée
window.CONFIG = { window.CONFIG = {
ID_ALEX: <?php echo defined('ID_ALEX') ? ID_ALEX : 2; ?>, // Liste complète des profils de la famille (Membres, Nounou, Enfants)
ID_LAIA: <?php echo defined('ID_LAIA') ? ID_LAIA : 3; ?>, PEOPLE: <?= isset($familyPeople) ? json_encode($familyPeople) : '[]' ?>,
// ID du user global connecté (pour savoir "qui" clique)
CURRENT_USER_ID: <?= isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : 'null' ?>,
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>', CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>',
ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>' ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>'
}; };
+5
View File
@@ -54,3 +54,8 @@ function verify_csrf(?string $token): bool
} }
return hash_equals($_SESSION['csrf_token'], $token); return hash_equals($_SESSION['csrf_token'], $token);
} }
if (!isset($familyPeople) && !empty($family_id)) {
require_once __DIR__ . '/db.php';
$stmtPeople = $pdo->query("SELECT id, name, user_id FROM pf_people ORDER BY id ASC");
$familyPeople = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
}
-7
View File
@@ -1,7 +0,0 @@
<?php
// includes/config.php
// HouseHub Config - Version dynamique
if (!defined('ID_ALEX')) {
define('ID_ALEX', 2);
define('ID_LAIA', 3);
}
-4
View File
@@ -1,8 +1,4 @@
<?php <?php
if (file_exists(__DIR__ . '/config.php')) {
require_once __DIR__ . '/config.php';
}
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
session_start(); session_start();
} }
-8
View File
@@ -18,14 +18,6 @@ require __DIR__ . '/header.php';
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button> <button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
</div> </div>
<!-- Formulaire de création de liste inline -->
<div class="liste-new-form hidden" id="liste-new-form">
<input type="text" id="new-list-name" maxlength="100"
placeholder="<?= htmlspecialchars(tr('liste_list_name_placeholder')) ?>" autocomplete="off">
<button id="btn-confirm-new-list"><?= htmlspecialchars(tr('liste_create')) ?></button>
<button id="btn-cancel-new-list"><?= htmlspecialchars(tr('cancel')) ?></button>
</div>
<!-- Corps de la liste active --> <!-- Corps de la liste active -->
<div class="liste-body" id="liste-body"> <div class="liste-body" id="liste-body">
+191 -19
View File
@@ -1,46 +1,218 @@
<?php <?php
// Script de migration pour mettre à jour TOUTES les bases de données familiales // Script de migration : Refonte globale Multi-Tenant & Synchronisation des schémas
require_once __DIR__ . '/includes/meta_db.php'; require_once __DIR__ . '/includes/meta_db.php';
echo "<h1>🚀 Début de la migration...</h1>"; echo "<h1>🚀 Début de la migration Globale ...</h1>";
// 1. Récupérer toutes les bases de données des familles // ==========================================
$stmt = $meta_pdo->query("SELECT name, db_name FROM families WHERE db_name != ''"); // 0. MISE À JOUR DE LA META DB
// ==========================================
echo "<h3>Mise à jour Meta DB (househub_meta)</h3><ul>";
try {
$meta_pdo->exec("ALTER TABLE user_calendar_integrations ADD COLUMN calendar_prefs_json TEXT DEFAULT NULL");
echo "<li><span style='color:green'>Colonne 'calendar_prefs_json' ajoutée à user_calendar_integrations.</span></li>";
} catch (\PDOException $e) {
if ($e->getCode() == '42S21' || strpos($e->getMessage(), '1060') !== false) {
echo "<li><span style='color:gray'>Colonne 'calendar_prefs_json' déjà présente.</span></li>";
} else { throw $e; }
}
echo "</ul>";
// ==========================================
// MIGRATIONS PAR FAMILLE
// ==========================================
$stmt = $meta_pdo->query("SELECT id, name, db_name FROM families WHERE db_name != ''");
$families = $stmt->fetchAll(); $families = $stmt->fetchAll();
$host = getenv('DB_HOST') ?: 'househub-db'; $host = getenv('DB_HOST') ?: 'househub-db';
$user = getenv('DB_USER') ?: 'househub'; $user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme'; $pass = getenv('DB_PASS') ?: 'changeme';
// 2. Boucler sur chaque famille
foreach ($families as $f) { foreach ($families as $f) {
$db_name = $f['db_name']; $db_name = $f['db_name'];
echo "Mise à jour de la famille <strong>{$f['name']}</strong> (Base : $db_name) ... "; $family_id = $f['id'];
echo "<h3>Mise à jour de <strong>{$f['name']}</strong> ($db_name)</h3><ul>";
$p1_name = null;
$p2_name = null;
try { try {
$fam_pdo = new PDO("mysql:host=$host;dbname=$db_name;charset=utf8mb4", $user, $pass, [ $fam_pdo = new PDO("mysql:host=$host;dbname=$db_name;charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]); ]);
// 3. Injecter la nouvelle table // 1. Table pf_foyer_settings
$sql = " $fam_pdo->exec("CREATE TABLE IF NOT EXISTS pf_foyer_settings (
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT '€', currency VARCHAR(10) NOT NULL DEFAULT '€',
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C', zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
$fam_pdo->exec("INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '€', 'C');");
echo "<li><span style='color:green'>Table pf_foyer_settings vérifiée/créée.</span></li>";
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '€', 'C'); // 2. Colonne external_href pour le Calendrier iOS
"; try {
$fam_pdo->exec("ALTER TABLE pf_calendar_event_links ADD COLUMN external_href VARCHAR(2048) DEFAULT NULL");
$fam_pdo->exec($sql); echo "<li><span style='color:green'>Colonne 'external_href' ajoutée à pf_calendar_event_links.</span></li>";
echo "<span style='color:green'>✅ OK</span><br>"; } catch (\PDOException $e) {
if ($e->getCode() != '42S21' && strpos($e->getMessage(), '1060') === false) throw $e;
}
// 3. Colonne budget_item_id pour les règles d'import
try {
$fam_pdo->exec("ALTER TABLE pf_import_rules ADD COLUMN budget_item_id INT(11) DEFAULT NULL");
echo "<li><span style='color:green'>Colonne 'budget_item_id' ajoutée à pf_import_rules.</span></li>";
} catch (\PDOException $e) {
if ($e->getCode() != '42S21' && strpos($e->getMessage(), '1060') === false) throw $e;
}
// 4. Uniformisation des VARCHAR de dates pour le budget (YYYY-MM-01 = 10 chars)
$fam_pdo->exec("ALTER TABLE pf_expenses MODIFY gestion_month VARCHAR(10) NOT NULL");
$fam_pdo->exec("ALTER TABLE pf_alloc_values MODIFY month_date VARCHAR(10) NOT NULL");
$fam_pdo->exec("ALTER TABLE pf_savings MODIFY month_date VARCHAR(10) NOT NULL");
echo "<li><span style='color:green'>Formats de dates (VARCHAR 10) uniformisés pour le budget.</span></li>";
// 5. GESTION DE PF_PEOPLE (user_id, role, color)
echo "<li><strong>Mise à jour pf_people :</strong> ";
try { $fam_pdo->exec("ALTER TABLE pf_people ADD COLUMN user_id INT NULL DEFAULT NULL"); } catch (\Exception $e) {}
try { $fam_pdo->exec("ALTER TABLE pf_people ADD COLUMN role VARCHAR(50) DEFAULT NULL"); } catch (\Exception $e) {}
try { $fam_pdo->exec("ALTER TABLE pf_people ADD COLUMN color VARCHAR(7) DEFAULT '#0891b2'"); } catch (\Exception $e) {}
$stmtUsers = $meta_pdo->prepare("SELECT id, username, display_name FROM users WHERE family_id = ? ORDER BY id ASC");
$stmtUsers->execute([$family_id]);
$users = $stmtUsers->fetchAll();
foreach ($users as $u) {
$stmtCheck = $fam_pdo->prepare("SELECT id FROM pf_people WHERE user_id = ? OR LOWER(name) = LOWER(?)");
$stmtCheck->execute([$u['id'], $u['username']]);
$exists = $stmtCheck->fetchColumn();
if ($exists) {
$fam_pdo->prepare("UPDATE pf_people SET user_id = ? WHERE id = ?")->execute([$u['id'], $exists]);
} else {
$fam_pdo->prepare("INSERT INTO pf_people (name, user_id) VALUES (?, ?)")->execute([$u['display_name'] ?: $u['username'], $u['id']]);
}
}
$fam_pdo->exec("UPDATE pf_people SET role = 'parent' WHERE user_id IS NOT NULL AND role IS NULL;");
$fam_pdo->exec("UPDATE pf_people SET role = 'nounou' WHERE LOWER(name) = 'carole';");
$pIds = $fam_pdo->query("SELECT id FROM pf_people WHERE role = 'parent' ORDER BY id ASC")->fetchAll(PDO::FETCH_COLUMN);
if (isset($pIds[0])) $fam_pdo->exec("UPDATE pf_people SET color = '#0891b2' WHERE id = " . (int)$pIds[0] . " AND color IS NULL");
if (isset($pIds[1])) $fam_pdo->exec("UPDATE pf_people SET color = '#f59e0b' WHERE id = " . (int)$pIds[1] . " AND color IS NULL");
echo "<span style='color:blue'>OK</span></li>";
// ==========================================
// 6. REFONTE RELATIONNELLE : pf_alloc_values (Colonnes -> Lignes)
// ==========================================
echo "<li><strong>Table pf_alloc_values (Normalisation) :</strong> ";
// Vérifier si la table est déjà convertie
$checkNewFormat = $fam_pdo->query("SHOW COLUMNS FROM pf_alloc_values LIKE 'person_id'")->rowCount();
if ($checkNewFormat > 0) {
echo "<span style='color:gray'>Déjà convertie au format relationnel.</span></li>";
} else {
// A. Récupérer l'ordre des parents réels pour faire le mapping d'index
$stmtParents = $fam_pdo->query("SELECT id, name FROM pf_people WHERE role = 'parent' ORDER BY id ASC");
$orderedParents = $stmtParents->fetchAll();
// B. Sauvegarder les anciennes données à migrer
$oldAllocations = $fam_pdo->query("SELECT * FROM pf_alloc_values")->fetchAll(PDO::FETCH_ASSOC);
// C. Supprimer l'ancienne table
$fam_pdo->exec("DROP TABLE IF EXISTS pf_alloc_values");
// D. Créer la nouvelle table normalisée
$fam_pdo->exec("CREATE TABLE pf_alloc_values (
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;");
// E. Migration des données
$stmtInsert = $fam_pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, person_id, amount) VALUES (?, ?, ?, ?)");
$migratedRows = 0;
foreach ($oldAllocations as $oldRow) {
// Mapping des anciennes colonnes potentielles vers les index de parents
$possibleColumns = [
0 => ['amount_p1', 'amount_alex'],
1 => ['amount_p2', 'amount_laia'],
2 => ['amount_p3'],
3 => ['amount_p4']
];
foreach ($possibleColumns as $parentIndex => $colNames) {
if (!isset($orderedParents[$parentIndex])) continue;
$targetParentId = $orderedParents[$parentIndex]['id'];
foreach ($colNames as $col) {
if (isset($oldRow[$col]) && (float)$oldRow[$col] > 0) {
$stmtInsert->execute([
$oldRow['month_date'],
$oldRow['cat_id'],
$targetParentId,
(float)$oldRow[$col]
]);
$migratedRows++;
break; // Passer à l'index parent suivant dès qu'on a trouvé une valeur
}
}
}
}
echo "<span style='color:green'>Succès ! Nouvelle table créée, $migratedRows lignes migrées.</span></li>";
}
// 7. GESTION DU BUDGET (pf_alloc_categories & pf_salary_config)
$fam_pdo->exec("UPDATE pf_alloc_categories SET name = 'Eco P1' WHERE name LIKE 'Eco Alex%'");
$fam_pdo->exec("UPDATE pf_alloc_categories SET name = 'Eco P2' WHERE name LIKE 'Eco Laia%'");
$stmtParents = $fam_pdo->query("SELECT name FROM pf_people WHERE role = 'parent' ORDER BY id ASC");
$parents = $stmtParents->fetchAll();
if (count($parents) >= 2) {
$fam_pdo->prepare("UPDATE pf_salary_config SET person = ? WHERE person = 'Alex'")->execute([$parents[0]['name']]);
$fam_pdo->prepare("UPDATE pf_salary_config SET person = ? WHERE person = 'Laia'")->execute([$parents[1]['name']]);
}
} catch (\PDOException $e) { } catch (\PDOException $e) {
echo "<span style='color:red'>❌ Erreur : " . $e->getMessage() . "</span><br>"; echo "<li style='color:red'>❌ Erreur : " . $e->getMessage() . "</li>";
} }
echo "</ul>";
// ==========================================
// 8. SEPARATION CIBLE BUDGET / DESTINATION VIREMENT
// ==========================================
echo "<li><strong>Table pf_alloc_categories (Fix Collision) :</strong> ";
try {
$fam_pdo->exec("ALTER TABLE pf_alloc_categories ADD COLUMN transfer_dest VARCHAR(50) DEFAULT NULL AFTER target");
echo "<span style='color:green'>Succès ! Colonne 'transfer_dest' ajoutée pour préserver vos objectifs chiffrés.</span></li>";
} catch (\PDOException $e) {
echo "<span style='color:gray'>La colonne transfer_dest existe déjà.</span></li>";
}
// ==========================================
// 9. TRANSFERT DES DONNÉES (Cible -> Destination)
// ==========================================
echo "<li><strong>Table pf_alloc_categories (Récupération des données) :</strong> ";
try {
// Déplacer les textes (qui commencent par "vers") dans transfer_dest
$updated = $fam_pdo->exec("UPDATE pf_alloc_categories SET transfer_dest = target, target = '0' WHERE target LIKE 'vers %'");
echo "<span style='color:green'>$updated destinations récupérées et déplacées proprement.</span></li>";
// Remettre la colonne target en format numérique maintenant qu'elle est propre
$fam_pdo->exec("ALTER TABLE pf_alloc_categories MODIFY target DECIMAL(10,2) DEFAULT 0.00");
echo "<li><span style='color:green'>Colonne 'target' re-sécurisée en format monétaire DECIMAL(10,2).</span></li>";
} catch (\PDOException $e) {
echo "<span style='color:red'>Erreur : " . $e->getMessage() . "</span></li>";
}
} }
echo "<h2>🎉 Migration terminée ! Tu peux supprimer ce fichier.</h2>";
echo "<h2>🎉 Migration terminée avec succès !</h2>";
?> ?>
+273 -69
View File
@@ -1,7 +1,6 @@
/* modules/budget/budget.css */ /* modules/budget/budget.css */
/* --- 1. VARIABLES & BASE --- */ /* --- 1. VARIABLES & BASE --- */
/* Le root garde les couleurs sémantiques dont le JS/PHP a besoin localement */
.budget-view { .budget-view {
font-family: font-family:
"Segoe UI", "Segoe UI",
@@ -128,7 +127,7 @@
/* --- 3. BOUTONS SPÉCIFIQUES & ACTIONS --- */ /* --- 3. BOUTONS SPÉCIFIQUES & ACTIONS --- */
.pf-btn { .pf-btn {
border-radius: 50px; /* Surcharge le global pour le budget */ border-radius: 50px;
} }
.btn-icon { .btn-icon {
background: transparent; background: transparent;
@@ -263,7 +262,6 @@ td:hover .btn-cell-delete {
border-top: 1px solid #e2e8f0; border-top: 1px solid #e2e8f0;
} }
/* Inputs modifiés */
.form-row { .form-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -346,20 +344,94 @@ input[type="number"] {
gap: 30px; gap: 30px;
} }
.prev-section-header { .prev-section-header {
display: flex; display: flex !important;
justify-content: space-between; flex-direction: row !important;
align-items: center; justify-content: space-between !important;
margin-bottom: 15px; align-items: center !important;
padding-bottom: 10px; margin-bottom: 20px;
border-bottom: 1px solid #e2e8f0; padding: 15px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
width: 100% !important;
box-sizing: border-box;
} }
.prev-section-header h2 { .prev-section-header h2 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.3rem;
font-weight: 800; font-weight: 800;
color: #0f172a; color: #0f172a;
letter-spacing: -0.025em; letter-spacing: -0.025em;
} }
.prev-header-left {
display: flex;
gap: 10px;
align-items: center;
}
.prev-header-actions {
display: flex;
gap: 10px;
}
/* Configuration Chromatique Parent 1 */
.txt-p1,
.txt-alex {
color: color-mix(in srgb, var(--p1-main, #0891b2) 85%, #000000) !important;
font-weight: bold;
text-align: center;
}
.col-p1,
.col-alex {
color: color-mix(in srgb, var(--p1-main, #0891b2) 85%, #000000) !important;
text-align: center;
}
th.col-p1,
td.col-p1 {
background-color: color-mix(
in srgb,
var(--p1-main, #0891b2) 8%,
#ffffff
) !important;
text-align: center;
}
input.txt-p1 {
color: color-mix(in srgb, var(--p1-main, #0891b2) 85%, #000000) !important;
text-align: center;
font-weight: 600;
}
input.txt-p1:focus {
border-color: var(--p1-main, #0891b2) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--p1-main, #0891b2) 10%, #ffffff) !important;
}
/* Configuration Chromatique Parent 2 */
.txt-p2 {
color: color-mix(in srgb, var(--p2-main, #f59e0b) 85%, #000000) !important;
font-weight: bold;
text-align: center;
}
.col-p2 {
color: color-mix(in srgb, var(--p2-main, #f59e0b) 85%, #000000) !important;
text-align: center;
}
th.col-p2,
td.col-p2 {
background-color: color-mix(
in srgb,
var(--p2-main, #f59e0b) 8%,
#ffffff
) !important;
text-align: center;
}
input.txt-p2 {
color: color-mix(in srgb, var(--p2-main, #f59e0b) 85%, #000000) !important;
text-align: center;
font-weight: 600;
}
input.txt-p2:focus {
border-color: var(--p2-main, #f59e0b) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--p2-main, #f59e0b) 10%, #ffffff) !important;
}
.prev-input { .prev-input {
width: 100%; width: 100%;
@@ -385,19 +457,6 @@ input[type="number"] {
font-weight: 700; font-weight: 700;
} }
.prev-section-header,
.view-header,
.budget-view > div[style*="justify-content: space-between"] {
display: flex !important;
flex-direction: row !important;
justify-content: space-between !important;
align-items: center !important;
float: none !important;
position: relative !important;
width: 100% !important;
gap: 15px !important;
}
.prev-salary-table { .prev-salary-table {
width: 100%; width: 100%;
max-width: 900px; max-width: 900px;
@@ -419,6 +478,15 @@ input[type="number"] {
text-align: center; text-align: center;
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;
} }
.prev-salary-table th small {
font-weight: normal;
text-transform: none;
}
.prev-salary-table th.th-family-savings,
.prev-salary-table td.td-family-savings {
background: #f0f9ff !important;
color: #0284c7 !important;
}
.prev-salary-table th:first-child { .prev-salary-table th:first-child {
text-align: left; text-align: left;
} }
@@ -426,6 +494,22 @@ input[type="number"] {
padding: 8px 12px; padding: 8px 12px;
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light);
text-align: center; text-align: center;
vertical-align: middle;
}
.prev-salary-table tbody tr:nth-child(1) td:first-child {
text-align: left !important;
font-weight: bold !important;
color: var(--text-main) !important;
border-left: 4px solid var(--p1-main, #0891b2) !important;
}
/* Parent 2 (Ligne 2, ex: Laia) */
.prev-salary-table tbody tr:nth-child(2) td:first-child {
text-align: left !important;
font-weight: bold !important;
color: var(--text-main) !important;
border-left: 4px solid var(--p2-main, #f59e0b) !important;
} }
.prev-timeline-wrapper { .prev-timeline-wrapper {
@@ -455,7 +539,7 @@ input[type="number"] {
border-right: 1px solid var(--border-light); border-right: 1px solid var(--border-light);
white-space: nowrap; white-space: nowrap;
text-align: center; text-align: center;
font-size: 0.9rem; vertical-align: middle;
} }
.col-sticky { .col-sticky {
@@ -490,6 +574,15 @@ input[type="number"] {
border-left: 2px solid white; border-left: 2px solid white;
padding: 8px !important; padding: 8px !important;
} }
.th-month span {
text-transform: capitalize;
}
.th-month .cycle-start-label {
font-size: 0.75rem;
font-weight: normal;
color: #64748b;
margin-top: 2px;
}
.th-month.current { .th-month.current {
background: var(--primary) !important; background: var(--primary) !important;
} }
@@ -511,16 +604,42 @@ input[type="number"] {
font-weight: 700; font-weight: 700;
border-bottom: 2px solid #bfdbfe; border-bottom: 2px solid #bfdbfe;
} }
.txt-alex { .row-restant td.td-separator {
color: #0891b2; border-left: 2px solid #cbd5e1;
background: #e2e8f0;
} }
.txt-laia { .row-indicative {
color: #f59e0b; background: #f8fafc;
color: #94a3b8;
}
.row-indicative .col-sticky {
opacity: 0.8;
}
.cat-name-label {
font-weight: 600;
color: var(--text-main);
}
.row-indicative .cat-name-label {
color: #64748b;
}
.row-indicative .cat-name-label span {
font-size: 0.7rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 0 4px;
margin-left: 5px;
}
.cat-target-label {
font-size: 0.75rem;
color: var(--text-muted);
font-style: italic;
text-align: right;
} }
.txt-global { .txt-global {
color: var(--text-muted); color: var(--text-muted);
font-weight: 700; font-weight: 700;
background: #f8fafc; background: #f8fafc;
border-left: 2px solid #e2e8f0;
} }
.val-ok { .val-ok {
color: var(--success); color: var(--success);
@@ -594,6 +713,9 @@ tr:hover .row-actions {
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-align: center; text-align: center;
} }
.recap-header span {
text-transform: capitalize;
}
.recap-table { .recap-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -603,6 +725,7 @@ tr:hover .row-actions {
padding: 10px 15px; padding: 10px 15px;
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light);
text-align: center; text-align: center;
vertical-align: middle;
font-size: 0.95rem; font-size: 0.95rem;
} }
.recap-table th { .recap-table th {
@@ -618,30 +741,68 @@ tr:hover .row-actions {
font-weight: 600; font-weight: 600;
color: #1e293b; color: #1e293b;
} }
.col-alex {
background-color: #ecfeff; .recap-th-content {
color: #0891b2; display: flex;
border-left: 1px solid #cffafe; flex-direction: column;
align-items: center;
gap: 5px;
} }
th.col-alex { .recap-th-content span {
background-color: #cffafe; text-transform: uppercase;
border-bottom-color: #a5f3fc;
} }
.col-laia { .validation-badge {
background-color: #fffbeb; background: #10b981;
color: #d97706; color: white;
border-left: 1px solid #fef3c7; padding: 4px 8px;
border-radius: 4px;
font-size: 0.7rem;
display: flex;
align-items: center;
gap: 4px;
} }
th.col-laia {
background-color: #fef3c7; /* --- Style de base du bouton de validation (Générique) --- */
border-bottom-color: #fde68a; .recap-table .pf-btn.btn-small {
background: #ffffff !important;
font-size: 0.7rem !important;
padding: 2px 8px !important;
height: auto !important;
width: auto !important;
font-weight: 600;
border-radius: 9px !important;
box-shadow: none !important;
transform: none !important;
transition: all 0.2s ease;
} }
.col-global {
background-color: #f8fafc; /* Couleurs dynamiques adaptées au Parent 1 (ex: Féfé) */
color: #475569; .col-p1 .pf-btn.btn-small {
font-weight: 700; color: var(--p1-main, #0891b2) !important;
border-left: 2px solid #e2e8f0; border: 1px solid var(--p1-main, #0891b2) !important;
} }
.col-p1 .pf-btn.btn-small:hover {
background: color-mix(
in srgb,
var(--p1-main, #0891b2) 12%,
#ffffff
) !important;
}
/* Couleurs dynamiques adaptées au Parent 2 (ex: Laia) */
.col-p2 .pf-btn.btn-small {
color: var(--p2-main, #f59e0b) !important;
border: 1px solid var(--p2-main, #f59e0b) !important;
}
.col-p2 .pf-btn.btn-small:hover {
background: color-mix(
in srgb,
var(--p2-main, #f59e0b) 12%,
#ffffff
) !important;
}
/* Grand Totaux Structuraux */
.row-grand-total td { .row-grand-total td {
background-color: #1e293b; background-color: #1e293b;
color: white; color: white;
@@ -649,14 +810,22 @@ th.col-laia {
border: none; border: none;
padding: 12px 15px; padding: 12px 15px;
} }
.row-grand-total .col-alex { .row-grand-total .col-p1 {
background: #164e63; background: color-mix(
color: #67e8f9; in srgb,
var(--p1-main, #0891b2) 65%,
#000000
) !important;
color: color-mix(in srgb, var(--p1-main, #0891b2) 20%, #ffffff) !important;
border: none; border: none;
} }
.row-grand-total .col-laia { .row-grand-total .col-p2 {
background: #78350f; background: color-mix(
color: #fcd34d; in srgb,
var(--p2-main, #f59e0b) 65%,
#000000
) !important;
color: color-mix(in srgb, var(--p2-main, #f59e0b) 20%, #ffffff) !important;
border: none; border: none;
} }
.row-grand-total .col-global { .row-grand-total .col-global {
@@ -665,7 +834,7 @@ th.col-laia {
border: none; border: none;
} }
/* --- 9. STYLES SPÉCIFIQUES AU SUIVI MENSUEL --- */ /* --- 9. STYLES DU SUIVI MENSUEL --- */
.cat-card { .cat-card {
background: var(--bg-panel); background: var(--bg-panel);
border-radius: var(--radius); border-radius: var(--radius);
@@ -675,20 +844,17 @@ th.col-laia {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.cat-card-header { .cat-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 15px; /* Padding interne à l'encart coloré */ padding: 15px;
width: 100%; width: 100%;
} }
.cat-card-title-group { .cat-card-title-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.cat-card-subtitle { .cat-card-subtitle {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: var(--text-muted);
@@ -768,7 +934,52 @@ input[type="month"].pf-input {
border-radius: 6px; border-radius: 6px;
} }
/* --- 10. EPARGNE --- */ /* --- 10. SECTION NOTES BUDGETAIRES --- */
.budget-notes-section {
margin: 20px 0;
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: var(--shadow-sm);
border: 1px solid #e2e8f0;
}
.notes-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.notes-header h3 {
margin: 0;
font-size: 1.1rem;
color: #1e293b;
}
.notes-header h3 span {
text-transform: capitalize;
}
.note-save-indicator {
font-size: 0.85rem;
color: #10b981;
font-weight: bold;
opacity: 0;
transition: opacity 0.3s;
}
#monthNoteArea {
width: 100%;
resize: vertical;
border-color: #cbd5e1;
background: #f8fafc;
margin-bottom: 10px;
}
.notes-footer {
text-align: right;
}
.notes-footer .pf-btn {
width: auto;
display: inline-flex;
}
/* --- 11. EPARGNE --- */
.epargne-inline-input { .epargne-inline-input {
width: 100%; width: 100%;
min-width: 60px; min-width: 60px;
@@ -793,7 +1004,7 @@ input[type="month"].pf-input {
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
} }
/* --- 11. CALCULATRICE FLOTTANTE --- */ /* --- 12. CALCULATRICE FLOTTANTE --- */
.pf-fab-sum { .pf-fab-sum {
position: fixed; position: fixed;
right: 30px; right: 30px;
@@ -883,7 +1094,7 @@ body.sum-mode-active .sum-target {
transition: background-color 0.2s; transition: background-color 0.2s;
} }
/* --- 12. RESPONSIVE MOBILE BUDGET --- */ /* --- 13. RESPONSIVE MOBILE BUDGET --- */
@media (max-width: 768px) { @media (max-width: 768px) {
.budget-view { .budget-view {
padding: 10px !important; padding: 10px !important;
@@ -917,11 +1128,6 @@ body.sum-mode-active .sum-target {
.budget-view .categories-grid > .cat-card { .budget-view .categories-grid > .cat-card {
padding: 0 !important; padding: 0 !important;
} }
.budget-view
> div[style*="justify-content: space-between"]:not(.view-header) {
flex-direction: column !important;
align-items: stretch !important;
}
.budget-view .budget-view
> div[style*="justify-content: space-between"]:not(.view-header) { > div[style*="justify-content: space-between"]:not(.view-header) {
flex-direction: column !important; flex-direction: column !important;
@@ -1021,7 +1227,6 @@ body.sum-mode-active .sum-target {
justify-content: flex-start; justify-content: flex-start;
width: auto !important; width: auto !important;
} }
.col-sticky, .col-sticky,
.sticky-col { .sticky-col {
min-width: 120px !important; min-width: 120px !important;
@@ -1059,8 +1264,7 @@ body.sum-mode-active .sum-target {
right: auto; right: auto;
top: auto; top: auto;
} }
.cat-card-header { .cat-card-header {
padding: 12px 10px; /* Plus serré sur mobile */ padding: 12px 10px;
} }
} }
+87 -75
View File
@@ -60,59 +60,85 @@ if ($action === 'update_salary_config') {
// 2. MISE A JOUR TABLEAU REPARTITION (AJAX) // 2. MISE A JOUR TABLEAU REPARTITION (AJAX)
if ($action === 'update_allocation') { if ($action === 'update_allocation') {
header('Content-Type: application/json'); header('Content-Type: application/json');
$date = $_POST['month_date']; $date = $_POST['month_date'];
$catId = $_POST['cat_id']; $catId = (int)$_POST['cat_id'];
$person = $_POST['person']; // 'amount_alex' ou 'amount_laia' $personId = (int)$_POST['person_id']; // 🟢 Reçoit l'ID de la personne directement !
$value = floatval($_POST['value']); $value = floatval($_POST['value']);
$stmt = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, $person) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE $person = VALUES($person)"); if ($catId <= 0 || $personId <= 0) {
$stmt->execute([$date, $catId, $value]); echo json_encode(['success' => false, 'error' => 'Données invalides.']);
exit;
}
$stmt = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, person_id, amount)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE amount = VALUES(amount)");
$stmt->execute([$date, $catId, $personId, $value]);
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
exit; exit;
} }
// 3. GESTION DES CATEGORIES (Ajout) // 3. GESTION DES CATEGORIES (Ajout)
if ($action === 'add_category') { if ($action === 'add_category') {
$name = trim($_POST['name']); try {
$target = trim($_POST['target']); $name = trim($_POST['name']);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; $transfer_dest = trim($_POST['transfer_dest'] ?? '');
$target = floatval($_POST['target'] ?? 0);
if (!empty($name)) { $holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
$stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, holiday_id) VALUES (?, ?, ?)");
$stmt->execute([$name, $target, $holiday_id]); if (!empty($name)) {
} $stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, transfer_dest, holiday_id) VALUES (?, ?, ?, ?)");
$stmt->execute([$name, $target, $transfer_dest, $holiday_id]);
// --- NOUVEAU : Réponse AJAX --- }
if (isset($_POST['ajax'])) {
header('Content-Type: application/json'); if (isset($_POST['ajax'])) {
echo json_encode(['success' => true]); header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
}
header("Location: " . $_SERVER['HTTP_REFERER']);
exit; exit;
} catch (Exception $e) {
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
}
die($e->getMessage());
} }
header("Location: " . $_SERVER['HTTP_REFERER']);
exit;
} }
// 4. MODIFICATION D'UNE CATEGORIE // 4. MODIFICATION D'UNE CATEGORIE
if ($action === 'update_category') { if ($action === 'update_category') {
$id = (int)$_POST['cat_id']; try {
$name = trim($_POST['name']); $id = (int)$_POST['cat_id'];
$target = trim($_POST['target']); $name = trim($_POST['name']);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; $transfer_dest = trim($_POST['transfer_dest'] ?? '');
$target = floatval($_POST['target'] ?? 0);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
if ($id > 0 && !empty($name)) { if ($id > 0 && !empty($name)) {
$stmt = $pdo->prepare("UPDATE pf_alloc_categories SET name = ?, target = ?, holiday_id = ? WHERE id = ?"); $stmt = $pdo->prepare("UPDATE pf_alloc_categories SET name = ?, target = ?, transfer_dest = ?, holiday_id = ? WHERE id = ?");
$stmt->execute([$name, $target, $holiday_id, $id]); $stmt->execute([$name, $target, $transfer_dest, $holiday_id, $id]);
} }
// --- NOUVEAU : Réponse AJAX --- if (isset($_POST['ajax'])) {
if (isset($_POST['ajax'])) { header('Content-Type: application/json');
header('Content-Type: application/json'); echo json_encode(['success' => true]);
echo json_encode(['success' => true]); exit;
}
header("Location: " . $_SERVER['HTTP_REFERER']);
exit; exit;
} catch (Exception $e) {
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
}
die($e->getMessage());
} }
header("Location: " . $_SERVER['HTTP_REFERER']);
exit;
} }
// 5. SUPPRESSION CATEGORIE // 5. SUPPRESSION CATEGORIE
@@ -136,43 +162,48 @@ if ($action === 'delete_category') {
// 6. VALIDATION DES VIREMENTS (Complex Business Logic) // 6. VALIDATION DES VIREMENTS (Complex Business Logic)
if ($action === 'validate_transfers') { if ($action === 'validate_transfers') {
header('Content-Type: application/json'); header('Content-Type: application/json');
$person = $_POST['person']; $personId = (int)$_POST['person_id']; // Reçoit l'ID parent
$monthDate = $_POST['month_date']; $monthDate = $_POST['month_date'];
try { try {
$pdo->beginTransaction(); $pdo->beginTransaction();
$stmtP = $pdo->prepare("SELECT name FROM pf_people WHERE id = ?");
$stmtP->execute([$personId]);
$dbPersonName = $stmtP->fetchColumn();
if (!$dbPersonName) throw new Exception("Parent introuvable.");
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT v.*, c.name as cat_name, c.target, c.holiday_id SELECT v.amount, c.name as cat_name, c.transfer_dest, c.holiday_id
FROM pf_alloc_values v FROM pf_alloc_values v
JOIN pf_alloc_categories c ON v.cat_id = c.id JOIN pf_alloc_categories c ON v.cat_id = c.id
WHERE v.month_date = ? WHERE v.month_date = ? AND v.person_id = ?
"); ");
$stmt->execute([$monthDate]); $stmt->execute([$monthDate, $personId]);
$budgetLines = $stmt->fetchAll(PDO::FETCH_ASSOC); $budgetLines = $stmt->fetchAll(PDO::FETCH_ASSOC);
$transfersToDo = []; $transfersToDo = [];
foreach ($budgetLines as $line) { foreach ($budgetLines as $line) {
$amount = ($person === 'Alex') ? $line['amount_alex'] : $line['amount_laia']; $amount = (float)$line['amount'];
if ($amount <= 0) continue; if ($amount <= 0) continue;
$target = trim($line['target']); $dest = trim($line['transfer_dest']);
$catName = trim($line['cat_name']); $catName = trim($line['cat_name']);
$holidayId = $line['holiday_id']; $holidayId = $line['holiday_id'];
$targetOwner = null; $targetOwner = null;
if ($target === 'vers L.Perso') { $targetOwner = $person; } if ($dest === 'vers L.Perso') { $targetOwner = $dbPersonName; }
elseif ($target === 'vers L.Pol') { $targetOwner = 'Pol'; } elseif ($dest === 'vers L.Pol') { $targetOwner = 'Pol'; }
elseif ($target === 'vers L.Pep') { $targetOwner = 'Pep'; } elseif ($dest === 'vers L.Pep') { $targetOwner = 'Pep'; }
elseif ($target === 'vers commune') { continue; } elseif ($dest === 'vers commune') { continue; }
if ($targetOwner) { if ($targetOwner) {
if (!isset($transfersToDo[$targetOwner])) { if (!isset($transfersToDo[$targetOwner])) {
$transfersToDo[$targetOwner] = ['total_add' => 0, 'cats' => []]; $transfersToDo[$targetOwner] = ['total_add' => 0, 'cats' => []];
} }
$transfersToDo[$targetOwner]['total_add'] += $amount; $transfersToDo[$targetOwner]['total_add'] += $amount;
if (!isset($transfersToDo[$targetOwner]['cats'][$catName])) { if (!isset($transfersToDo[$targetOwner]['cats'][$catName])) {
$transfersToDo[$targetOwner]['cats'][$catName] = ['amount' => 0, 'holiday_id' => $holidayId]; $transfersToDo[$targetOwner]['cats'][$catName] = ['amount' => 0, 'holiday_id' => $holidayId];
} }
@@ -181,7 +212,6 @@ if ($action === 'validate_transfers') {
} }
foreach ($transfersToDo as $owner => $data) { foreach ($transfersToDo as $owner => $data) {
// A. VERIFIER EXISTENCE (Inchangé)
$stmtCheck = $pdo->prepare("SELECT COUNT(*) FROM pf_savings WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'"); $stmtCheck = $pdo->prepare("SELECT COUNT(*) FROM pf_savings WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'");
$stmtCheck->execute([$owner, $monthDate]); $stmtCheck->execute([$owner, $monthDate]);
$exists = $stmtCheck->fetchColumn() > 0; $exists = $stmtCheck->fetchColumn() > 0;
@@ -200,54 +230,38 @@ if ($action === 'validate_transfers') {
} }
} }
// B. UPDATE TOTAL (Inchangé)
$stmtUpdTotal = $pdo->prepare("UPDATE pf_savings SET amount = amount + ? WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'"); $stmtUpdTotal = $pdo->prepare("UPDATE pf_savings SET amount = amount + ? WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'");
$stmtUpdTotal->execute([$data['total_add'], $owner, $monthDate]); $stmtUpdTotal->execute([$data['total_add'], $owner, $monthDate]);
// C. UPDATE CATÉGORIES (Modifié pour gérer le holiday_id)
foreach ($data['cats'] as $catName => $catInfo) { foreach ($data['cats'] as $catName => $catInfo) {
$catAmount = $catInfo['amount']; $catAmount = $catInfo['amount'];
$catHolidayId = $catInfo['holiday_id']; // NOUVEAU $catHolidayId = $catInfo['holiday_id'];
if ($catName === 'Eco Alex' || $catName === 'Eco Laia') { continue; } if (strpos($catName, 'Eco P') === 0) { continue; }
$stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?"); $stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
$stmtCheckCat->execute([$owner, $monthDate, $catName]); $stmtCheckCat->execute([$owner, $monthDate, $catName]);
$catId = $stmtCheckCat->fetchColumn(); $catId = $stmtCheckCat->fetchColumn();
if ($catId) { if ($catId) {
// Update : On actualise aussi le holiday_id au cas où il aurait changé
$stmtUpdateCat = $pdo->prepare("UPDATE pf_savings SET amount = amount + ?, holiday_id = ? WHERE id = ?"); $stmtUpdateCat = $pdo->prepare("UPDATE pf_savings SET amount = amount + ?, holiday_id = ? WHERE id = ?");
$stmtUpdateCat->execute([$catAmount, $catHolidayId, $catId]); $stmtUpdateCat->execute([$catAmount, $catHolidayId, $catId]);
} else { } else {
// Insert
$stmtInsertCat = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)"); $stmtInsertCat = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)");
$stmtInsertCat->execute([$monthDate, $owner, $catName, $catAmount, $catHolidayId]); $stmtInsertCat->execute([$monthDate, $owner, $catName, $catAmount, $catHolidayId]);
} }
} }
} }
// 3. ENREGISTRER LA VALIDATION (Mise à jour table existante)
// a. Trouver l'ID de la catégorie système
$stmtSys = $pdo->prepare("SELECT id FROM pf_alloc_categories WHERE name = 'SYSTEM_VALIDATION' LIMIT 1"); $stmtSys = $pdo->prepare("SELECT id FROM pf_alloc_categories WHERE name = 'SYSTEM_VALIDATION' LIMIT 1");
$stmtSys->execute(); $stmtSys->execute();
$sysCatId = $stmtSys->fetchColumn(); $sysCatId = $stmtSys->fetchColumn();
if ($sysCatId) { if ($sysCatId) {
$stmtVal = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, person_id, amount)
if ($person === 'Alex') { VALUES (?, ?, ?, 1)
$sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia) ON DUPLICATE KEY UPDATE amount = 1");
VALUES (?, ?, 1, 0) $stmtVal->execute([$monthDate, $sysCatId, $personId]);
ON DUPLICATE KEY UPDATE amount_alex = 1";
} else {
$sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia)
VALUES (?, ?, 0, 1)
ON DUPLICATE KEY UPDATE amount_laia = 1";
}
$stmtVal = $pdo->prepare($sql);
$stmtVal->execute([$monthDate, $sysCatId]);
} }
$pdo->commit(); $pdo->commit();
@@ -258,6 +272,4 @@ if ($action === 'validate_transfers') {
echo json_encode(['success' => false, 'error' => $e->getMessage()]); echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} }
exit; exit;
} }
File diff suppressed because it is too large Load Diff
+39 -13
View File
@@ -57,12 +57,11 @@ if (isset($_POST['action']) && $_POST['action'] === 'reopen_month') {
exit; exit;
} }
if (isset($_POST['action']) && $_POST['action'] === 'save_import') { if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
$count = 0; $count = 0;
$stmtExp = $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); $stmtExp = $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$stmtRule = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category) VALUES (?, ?) ON DUPLICATE KEY UPDATE category = VALUES(category)"); // Mémorisation du mapping étendu (incluant budget_item_id)
$stmtRule = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category, budget_item_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE category = VALUES(category), budget_item_id = VALUES(budget_item_id)");
if (isset($_POST['lines']) && is_array($_POST['lines'])) { if (isset($_POST['lines']) && is_array($_POST['lines'])) {
foreach ($_POST['lines'] as $line) { foreach ($_POST['lines'] as $line) {
@@ -81,7 +80,7 @@ if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
try { try {
$stmtExp->execute([$dateToSave, $gestionMonthLine, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId]); $stmtExp->execute([$dateToSave, $gestionMonthLine, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId]);
$stmtRule->execute([$line['label'], $cat]); $stmtRule->execute([$line['label'], $cat, $budgetItemId]);
$count++; $count++;
} catch (Exception $e) { continue; } } catch (Exception $e) { continue; }
} }
@@ -110,7 +109,15 @@ $showPreview = false;
if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) { if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) {
$file = $_FILES['csv_file']['tmp_name']; $file = $_FILES['csv_file']['tmp_name'];
$handle = fopen($file, "r"); $handle = fopen($file, "r");
$rules = []; try { $rules = $pdo->query("SELECT keyword, category FROM pf_import_rules")->fetchAll(PDO::FETCH_KEY_PAIR); } catch(Exception $e){}
$rules = [];
try {
$stmtRules = $pdo->query("SELECT keyword, category, budget_item_id FROM pf_import_rules");
while ($r = $stmtRules->fetch(PDO::FETCH_ASSOC)) {
$rules[$r['keyword']] = ['cat' => $r['category'], 'budget_item_id' => $r['budget_item_id']];
}
} catch(Exception $e){}
$existingRefs = []; try { $existingRefs = $pdo->query("SELECT import_ref FROM pf_expenses WHERE import_ref IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN); } catch(Exception $e){} $existingRefs = []; try { $existingRefs = $pdo->query("SELECT import_ref FROM pf_expenses WHERE import_ref IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN); } catch(Exception $e){}
fgetcsv($handle, 1000, ";", "\"", "\\"); fgetcsv($handle, 1000, ";", "\"", "\\");
@@ -129,9 +136,16 @@ if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) {
$isDuplicate = in_array($uniqueKey, $existingRefs); $isDuplicate = in_array($uniqueKey, $existingRefs);
$suggestedCat = ''; $suggestedCat = '';
foreach ($rules as $kw => $c) { if (stripos($label, $kw) !== false) { $suggestedCat = $c; break; } } $suggestedItemId = null;
foreach ($rules as $kw => $ruleData) {
if (stripos($label, $kw) !== false) {
$suggestedCat = $ruleData['cat'];
$suggestedItemId = $ruleData['budget_item_id'];
break;
}
}
$csvData[] = ['date'=>$dateSql, 'label'=>$label, 'amount'=>$amount, 'cat'=>$suggestedCat, 'ref'=>$uniqueKey, 'is_duplicate'=>$isDuplicate, 'is_credit'=>$isCredit]; $csvData[] = ['date'=>$dateSql, 'label'=>$label, 'amount'=>$amount, 'cat'=>$suggestedCat, 'suggested_item_id'=>$suggestedItemId, 'ref'=>$uniqueKey, 'is_duplicate'=>$isDuplicate, 'is_credit'=>$isCredit];
} }
fclose($handle); fclose($handle);
$showPreview = true; $showPreview = true;
@@ -224,6 +238,7 @@ $categoriesConfig = [
'School' => ['type'=>'debit', 'label'=>tr('cat_school'), 'budget'=>$budget_school, 'color'=>'#10b981', 'suggestions'=>[]], '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'=>[]], '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']], 'Autres' => ['type'=>'debit', 'label'=>tr('cat_others'), 'budget'=>$budget_autres, 'color'=>'#64748b', 'suggestions'=>['Restaurant', 'Cadeau']],
'Apports' => ['type'=>'debit', 'label'=>tr('cat_contributions') ?? 'Apports & Projets', 'budget'=>0, 'color'=>'#0ea5e9', 'suggestions'=>['Alex', 'Laia', 'Remboursement']],
'LivretA' => ['type'=>'debit', 'label'=>tr('cat_savings'),'budget'=>0, 'color'=>'#8b5cf6', 'suggestions'=>['Virement']] 'LivretA' => ['type'=>'debit', 'label'=>tr('cat_savings'),'budget'=>0, 'color'=>'#8b5cf6', 'suggestions'=>['Virement']]
]; ];
@@ -695,11 +710,15 @@ $monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
</select> </select>
<select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-frais" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled> <select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-frais" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled>
<option value="">-- <?= tr('bud_is_charge') ?> --</option> <option value="">-- <?= tr('bud_is_charge') ?> --</option>
<?php foreach ($fixedChargesList as $fc): ?><option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['name']) ?></option><?php endforeach; ?> <?php foreach ($fixedChargesList as $fc): ?>
<option value="<?= $fc['id'] ?>" <?= ($row['suggested_item_id'] == $fc['id']) ? 'selected' : '' ?>><?= htmlspecialchars($fc['name']) ?></option>
<?php endforeach; ?>
</select> </select>
<select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-income" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled> <select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-income" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled>
<option value="">-- <?= tr('bud_is_income') ?> --</option> <option value="">-- <?= tr('bud_is_income') ?> --</option>
<?php foreach ($incomeList as $inc): ?><option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?></option><?php endforeach; ?> <?php foreach ($incomeList as $inc): ?>
<option value="<?= $inc['id'] ?>" <?= ($row['suggested_item_id'] == $inc['id']) ? 'selected' : '' ?>><?= htmlspecialchars($inc['name']) ?></option>
<?php endforeach; ?>
</select> </select>
</div> </div>
</td> </td>
@@ -792,10 +811,14 @@ function openEditModal(e) {
else if (e.category === 'Income') document.getElementById('incomeSelect').value = e.budget_item_id; else if (e.category === 'Income') document.getElementById('incomeSelect').value = e.budget_item_id;
} }
function handleLineCatChange(select) { function handleLineCatChange(select, isInit = false) {
const row = select.closest('tr'); const row = select.closest('tr');
const fSel = row.querySelector('.select-frais'); const iSel = row.querySelector('.select-income'); const fSel = row.querySelector('.select-frais'); const iSel = row.querySelector('.select-income');
fSel.style.display = 'none'; iSel.style.display = 'none'; fSel.value = ''; iSel.value = ''; fSel.disabled = true; iSel.disabled = true;
fSel.style.display = 'none'; iSel.style.display = 'none';
if (!isInit) { fSel.value = ''; iSel.value = ''; }
fSel.disabled = true; iSel.disabled = true;
if (select.value === 'Frais') { fSel.style.display = 'block'; fSel.disabled = false; } if (select.value === 'Frais') { fSel.style.display = 'block'; fSel.disabled = false; }
else if (select.value === 'Income') { iSel.style.display = 'block'; iSel.disabled = false; } else if (select.value === 'Income') { iSel.style.display = 'block'; iSel.disabled = false; }
checkValidation(); checkValidation();
@@ -815,7 +838,10 @@ function checkValidation() {
else { btn.disabled = false; btn.style.opacity=1; msg.style.display='none'; } else { btn.disabled = false; btn.style.opacity=1; msg.style.display='none'; }
} }
if(document.getElementById('formMapping')) { document.querySelectorAll('.line-select').forEach(s => handleLineCatChange(s)); checkValidation(); } if(document.getElementById('formMapping')) {
document.querySelectorAll('.line-select').forEach(s => handleLineCatChange(s, true));
checkValidation();
}
window.addEventListener('click', (e) => { window.addEventListener('click', (e) => {
if (e.target.classList.contains('pf-modal')) { if (e.target.classList.contains('pf-modal')) {
@@ -826,7 +852,7 @@ window.addEventListener('click', (e) => {
// --- 2. SUPPRESSION ASYNCHRONE --- // --- 2. SUPPRESSION ASYNCHRONE ---
async function deleteExpense(id) { async function deleteExpense(id) {
const confirmed = await pachaConfirm("Suppression", tr('bud_confirm_delete')); const confirmed = await pachaConfirm("Suppression", window.I18N['bud_confirm_delete']);
if (!confirmed) return; if (!confirmed) return;
const formData = new FormData(); const formData = new FormData();
+117 -88
View File
@@ -5,32 +5,42 @@ document.addEventListener("DOMContentLoaded", () => {
const CONGE_TYPES = ["OFF_CAROLE", "EXTRA_OFF_CAROLE"]; const CONGE_TYPES = ["OFF_CAROLE", "EXTRA_OFF_CAROLE"];
const GUARDE_TYPES = ["CENTRE", "AVIS"]; const GUARDE_TYPES = ["CENTRE", "AVIS"];
const PEP_TYPES = ["PEP_SICK"]; const PEP_TYPES = ["PEP_SICK"];
const FAMILY = {
ALEX: { id: 2, prefix: "alex" }, // 🟢 Liste globale dynamique des personnes (fini les IDs en dur !)
LAIA: { id: 3, prefix: "laia" }, const peopleList = window.CONFIG.PEOPLE || [];
};
// On filtre pour ne garder que les parents pour la logique principale des congés
const parents = peopleList.filter(
(p) => p.role && p.role.toLowerCase() === "parent",
);
const LEAVES_CONFIG = { const LEAVES_CONFIG = {
CP: { CP: {
startMonth: 8, // Le cycle commence en août (après la tolérance de juillet) startMonth: 8,
defaultBalance: 25, defaultBalance: 25,
}, },
JRA: { JRA: {
// Tu pourras ajouter les années suivantes ici
yearlyTotals: { yearlyTotals: {
2024: 10, // ex: 0.83 * 12 arrondi 2024: 10,
2025: 10, 2025: 10,
2026: 11, // ex: 0.9 * 12 arrondi 2026: 11,
}, },
defaultBalance: 10, defaultBalance: 10,
toleranceMonths: 2, // Janvier et Février toleranceMonths: 2,
maxReport: 2, maxReport: 2,
}, },
JA: { JA: {}, // Ce tableau est maintenant vide par défaut, on le remplit dynamiquement juste en dessous
[FAMILY.ALEX.id]: { startMonth: 4, startDay: 29, defaultBalance: 4 },
[FAMILY.LAIA.id]: { startMonth: 10, startDay: 1, defaultBalance: 4 }, // Date de Laia à adapter
},
}; };
// 🟢 INITIALISATION DYNAMIQUE DES JOURS D'ANNIVERSAIRE (JA)
parents.forEach((parent) => {
LEAVES_CONFIG.JA[parent.id] = {
startMonth: 4, // Mois par défaut (à faire évoluer en BDD au Niveau 3)
startDay: 29, // Jour par défaut
defaultBalance: 4,
};
});
class FamilyCalendar { class FamilyCalendar {
constructor() { constructor() {
this.planningBody = document.getElementById("planningBody"); this.planningBody = document.getElementById("planningBody");
@@ -455,12 +465,17 @@ document.addEventListener("DOMContentLoaded", () => {
const d = new Date(l.leave_date + "T00:00:00"); const d = new Date(l.leave_date + "T00:00:00");
if (d >= w.dayDates.mon && d <= w.dayDates.fri) { if (d >= w.dayDates.mon && d <= w.dayDates.fri) {
const dur = parseFloat(l.duration) || 1; const dur = parseFloat(l.duration) || 1;
const prefix =
l.person_id === FAMILY.ALEX.id // 🟢 Correspondance dynamique pour le stockage des totaux de la semaine
? FAMILY.ALEX.prefix let prefix = null;
: l.person_id === FAMILY.LAIA.id if (parents[0] && parseInt(l.person_id) === parseInt(parents[0].id))
? FAMILY.LAIA.prefix prefix = "alex";
: null; else if (
parents[1] &&
parseInt(l.person_id) === parseInt(parents[1].id)
)
prefix = "laia";
if (prefix) w.totals[`${prefix}${l.leave_type}`] += dur; if (prefix) w.totals[`${prefix}${l.leave_type}`] += dur;
} }
}); });
@@ -479,17 +494,16 @@ document.addEventListener("DOMContentLoaded", () => {
} }
calculateMonthlyBalances() { calculateMonthlyBalances() {
const balances = { const balances = {};
[FAMILY.ALEX.id]: { CP: {}, JRA: {}, JA: {} }, // 🟢 On initialise le tableau des soldes pour chaque parent trouvé
[FAMILY.LAIA.id]: { CP: {}, JRA: {}, JA: {} }, parents.forEach((p) => {
}; balances[p.id] = { CP: {}, JRA: {}, JA: {} };
});
// Liste des mois actuellement affichés dans le planning
const ymSet = new Set(); const ymSet = new Set();
this.weeks.forEach((w) => ymSet.add(w.monthKey)); this.weeks.forEach((w) => ymSet.add(w.monthKey));
const ymList = Array.from(ymSet).sort(); const ymList = Array.from(ymSet).sort();
// Pré-calcul de l'utilisation par mois
const usageByMonth = {}; const usageByMonth = {};
this.leaves.forEach((l) => { this.leaves.forEach((l) => {
const pid = l.person_id; const pid = l.person_id;
@@ -502,7 +516,8 @@ document.addEventListener("DOMContentLoaded", () => {
(usageByMonth[pid][type][ym] || 0) + parseFloat(l.duration); (usageByMonth[pid][type][ym] || 0) + parseFloat(l.duration);
}); });
[FAMILY.ALEX.id, FAMILY.LAIA.id].forEach((pid) => { parents.forEach((parent) => {
const pid = parent.id;
["CP", "JRA", "JA"].forEach((type) => { ["CP", "JRA", "JA"].forEach((type) => {
ymList.forEach((ym) => { ymList.forEach((ym) => {
const [currYear, currMonth] = ym.split("-").map(Number); const [currYear, currMonth] = ym.split("-").map(Number);
@@ -510,8 +525,6 @@ document.addEventListener("DOMContentLoaded", () => {
let cycleStartStr = ""; let cycleStartStr = "";
let initialBalance = 0; let initialBalance = 0;
// --- 1. RECHERCHE D'UN CORRECTIF MANUEL (SNAPSHOT) ---
// On cherche le snapshot le plus récent qui est inférieur ou égal au mois en cours de calcul
const latestSnapshot = (this.leaveSnapshots || []) const latestSnapshot = (this.leaveSnapshots || [])
.filter( .filter(
(s) => (s) =>
@@ -519,18 +532,14 @@ document.addEventListener("DOMContentLoaded", () => {
s.leave_type == type && s.leave_type == type &&
s.snapshot_date.substring(0, 7) <= ym, s.snapshot_date.substring(0, 7) <= ym,
) )
// On trie du plus récent au plus ancien pour prendre le premier
.sort((a, b) => .sort((a, b) =>
b.snapshot_date.localeCompare(a.snapshot_date), b.snapshot_date.localeCompare(a.snapshot_date),
)[0]; )[0];
if (latestSnapshot) { if (latestSnapshot) {
// Si on trouve un correctif, il devient notre nouveau "point zéro"
cycleStartStr = latestSnapshot.snapshot_date.substring(0, 7); cycleStartStr = latestSnapshot.snapshot_date.substring(0, 7);
initialBalance = parseFloat(latestSnapshot.remaining_balance); // Utilisation de VOTRE nom de colonne initialBalance = parseFloat(latestSnapshot.remaining_balance);
} } else {
// --- 2. SINON, CALCUL CLASSIQUE PAR DÉFAUT ---
else {
if (type === "CP") { if (type === "CP") {
const refYear = const refYear =
currMonth >= LEAVES_CONFIG.CP.startMonth currMonth >= LEAVES_CONFIG.CP.startMonth
@@ -552,7 +561,6 @@ document.addEventListener("DOMContentLoaded", () => {
LEAVES_CONFIG.JRA.yearlyTotals[currYear] || LEAVES_CONFIG.JRA.yearlyTotals[currYear] ||
LEAVES_CONFIG.JRA.defaultBalance; LEAVES_CONFIG.JRA.defaultBalance;
// Logique de report du reliquat de l'année N-1
if (currMonth <= LEAVES_CONFIG.JRA.toleranceMonths) { if (currMonth <= LEAVES_CONFIG.JRA.toleranceMonths) {
const prevYear = currYear - 1; const prevYear = currYear - 1;
const prevInitial = const prevInitial =
@@ -569,14 +577,10 @@ document.addEventListener("DOMContentLoaded", () => {
0, 0,
prevInitial - usedPrevYear, prevInitial - usedPrevYear,
); );
// Ajout du report plafonné à 2 jours
initialBalance += Math.min( initialBalance += Math.min(
remainingPrevYear, remainingPrevYear,
LEAVES_CONFIG.JRA.maxReport, LEAVES_CONFIG.JRA.maxReport,
); );
console.log(
`[JRA] Personne ${pid}, Année ${currYear}: Report de ${Math.min(remainingPrevYear, LEAVES_CONFIG.JRA.maxReport)}j inclus.`,
);
} }
} else if (type === "JA") { } else if (type === "JA") {
const configJA = LEAVES_CONFIG.JA[pid]; const configJA = LEAVES_CONFIG.JA[pid];
@@ -598,11 +602,8 @@ document.addEventListener("DOMContentLoaded", () => {
} }
} }
// --- 3. DÉDUCTION DES CONGÉS PRIS DEPUIS LE POINT ZÉRO ---
let usedBeforeCurrentMonth = 0; let usedBeforeCurrentMonth = 0;
Object.keys(usageByMonth[pid]?.[type] || {}).forEach((usedYm) => { Object.keys(usageByMonth[pid]?.[type] || {}).forEach((usedYm) => {
// On ne déduit que ce qui a été posé entre le début du cycle (ou la date du snapshot) et le mois en cours
if (usedYm >= cycleStartStr && usedYm < ym) { if (usedYm >= cycleStartStr && usedYm < ym) {
usedBeforeCurrentMonth += usageByMonth[pid][type][usedYm]; usedBeforeCurrentMonth += usageByMonth[pid][type][usedYm];
} }
@@ -688,10 +689,28 @@ document.addEventListener("DOMContentLoaded", () => {
const dayLeaves = this.leaves.filter((l) => l.leave_date === iso); const dayLeaves = this.leaves.filter((l) => l.leave_date === iso);
if (dayLeaves.length) { if (dayLeaves.length) {
let html = `<div style="position:absolute; bottom:0; left:0; width:100%; font-size:9px; line-height:1; display:flex; justify-content:center; gap:2px; pointer-events:none;">`; let html = `<div style="position:absolute; bottom:0; left:0; width:100%; font-size:9px; line-height:1; display:flex; justify-content:center; gap:2px; pointer-events:none;">`;
if (dayLeaves.some((l) => l.person_id === window.CONFIG.ID_ALEX))
html += `<span style="color:#0f766e; font-weight:800;">A</span>`; const colors = [
if (dayLeaves.some((l) => l.person_id === window.CONFIG.ID_LAIA)) "#0f766e",
html += `<span style="color:#b45309; font-weight:800;">L</span>`; "#b45309",
"#047857",
"#4338ca",
"#b91c1c",
];
if (window.CONFIG.PEOPLE && window.CONFIG.PEOPLE.length > 0) {
window.CONFIG.PEOPLE.forEach((person, index) => {
if (
dayLeaves.some(
(l) => parseInt(l.person_id) === parseInt(person.id),
)
) {
const color = colors[index % colors.length];
const initial = person.name.charAt(0).toUpperCase();
html += `<span style="color:${color}; font-weight:800; margin: 0 1px;">${initial}</span>`;
}
});
}
html += `</div>`; html += `</div>`;
td.innerHTML += html; td.innerHTML += html;
} }
@@ -731,8 +750,10 @@ document.addEventListener("DOMContentLoaded", () => {
tr.appendChild(tdUse); tr.appendChild(tdUse);
}); });
}; };
renderPersonCols(FAMILY.ALEX.id, `col-${FAMILY.ALEX.prefix}`); parents.forEach((parent, index) => {
renderPersonCols(FAMILY.LAIA.id, `col-${FAMILY.LAIA.prefix}`); const cssPrefix = index % 2 === 0 ? "col-alex" : "col-laia";
renderPersonCols(parent.id, cssPrefix);
});
} }
this.planningBody.appendChild(tr); this.planningBody.appendChild(tr);
}); });
@@ -845,9 +866,9 @@ document.addEventListener("DOMContentLoaded", () => {
container.style.display = "flex"; container.style.display = "flex";
let html = ""; let html = "";
[FAMILY.ALEX, FAMILY.LAIA].forEach((person) => { parents.forEach((person) => {
html += `<div class="fc-minimal-balance-card"> html += `<div class="fc-minimal-balance-card">
<strong class="${person.prefix}">${person.prefix.toUpperCase()}</strong> <strong class="col-alex">${person.name.toUpperCase()}</strong>
<div class="fc-minimal-chips">`; <div class="fc-minimal-chips">`;
["CP", "JRA", "JA"].forEach((type) => { ["CP", "JRA", "JA"].forEach((type) => {
@@ -989,10 +1010,19 @@ document.addEventListener("DOMContentLoaded", () => {
const dayLeaves = this.leaves.filter((l) => l.leave_date === iso); const dayLeaves = this.leaves.filter((l) => l.leave_date === iso);
if (dayLeaves.length) { if (dayLeaves.length) {
content += `<div style="position:absolute; bottom:2px; left:2px; font-size:10px; font-weight:bold;">`; content += `<div style="position:absolute; bottom:2px; left:2px; font-size:10px; font-weight:bold;">`;
if (dayLeaves.some((l) => l.person_id === 2))
content += `<span style="color:#0f766e">A</span> `; parents.forEach((parent, index) => {
if (dayLeaves.some((l) => l.person_id === 3)) if (
content += `<span style="color:#b45309">L</span>`; dayLeaves.some(
(l) => parseInt(l.person_id) === parseInt(parent.id),
)
) {
const color = index % 2 === 0 ? "#0f766e" : "#b45309";
const initial = parent.name.charAt(0).toUpperCase();
content += `<span style="color:${color}">${initial}</span> `;
}
});
content += `</div>`; content += `</div>`;
} }
content += `</div>`; content += `</div>`;
@@ -1557,7 +1587,9 @@ document.addEventListener("DOMContentLoaded", () => {
if (dates.includes(e.date)) activeEvents.add(e.type); if (dates.includes(e.date)) activeEvents.add(e.type);
}); });
const activeLeaves = { 2: new Set(), 3: new Set() }; const activeLeaves = {};
parents.forEach((p) => (activeLeaves[p.id] = new Set()));
this.leaves.forEach((l) => { this.leaves.forEach((l) => {
if (dates.includes(l.leave_date)) { if (dates.includes(l.leave_date)) {
if (activeLeaves[l.person_id]) if (activeLeaves[l.person_id])
@@ -1623,32 +1655,33 @@ document.addEventListener("DOMContentLoaded", () => {
<button class="fc-menu-btn ${getBtnClass("PEP_SICK")}" data-action="add" data-type="PEP_SICK" style="width:100%">${getBtnIcon("PEP_SICK")}${tr("leg_pep_sick")} 🤒</button> <button class="fc-menu-btn ${getBtnClass("PEP_SICK")}" data-action="add" data-type="PEP_SICK" style="width:100%">${getBtnIcon("PEP_SICK")}${tr("leg_pep_sick")} 🤒</button>
</div>`; </div>`;
// 5. Section Enfants // 5. Section Parents (Congés)
html += `<div class="fc-menu-section"> html += `<div class="fc-menu-section">
${buildHeader(tr("fc_menu_kids_leaves"), null, null)} ${buildHeader(tr("fc_menu_kids_leaves"), null, null)}
<div class="fc-menu-leaves-table"> <div class="fc-menu-leaves-table">
<table> <table>
<thead> <thead>
<tr> <tr>`;
<th>
<div class="fc-th-inline"> // En-têtes dynamiques (Noms + Bouton Poubelle)
Alex <button class="fc-menu-clear-icon fc-menu-btn-th" data-action="clear-leaves-person" data-pid="2" title="${tr("fc_clear")} Alex">${trashSvg}</button> parents.forEach((parent) => {
</div> html += `<th>
</th> <div class="fc-th-inline">
<th> ${parent.name} <button class="fc-menu-clear-icon fc-menu-btn-th" data-action="clear-leaves-person" data-pid="${parent.id}" title="${tr("fc_clear")} ${parent.name}">${trashSvg}</button>
<div class="fc-th-inline"> </div>
Laia <button class="fc-menu-clear-icon fc-menu-btn-th" data-action="clear-leaves-person" data-pid="3" title="${tr("fc_clear")} Laia">${trashSvg}</button> </th>`;
</div> });
</th> html += ` </tr>
</tr>
</thead> </thead>
<tbody>`; <tbody>`;
// Lignes de congés (CP, JRA, JA)
["CP", "JRA", "JA"].forEach((t) => { ["CP", "JRA", "JA"].forEach((t) => {
html += `<tr> html += `<tr>`;
<td><button class="fc-menu-btn ${getBtnClass(t, 2)}" data-action="add-leave" data-pid="2" data-type="${t}">${getBtnIcon(t, 2)}${t}</button></td> parents.forEach((parent) => {
<td><button class="fc-menu-btn ${getBtnClass(t, 3)}" data-action="add-leave" data-pid="3" data-type="${t}">${getBtnIcon(t, 3)}${t}</button></td> html += `<td><button class="fc-menu-btn ${getBtnClass(t, parent.id)}" data-action="add-leave" data-pid="${parent.id}" data-type="${t}">${getBtnIcon(t, parent.id)}${t}</button></td>`;
</tr>`; });
html += `</tr>`;
}); });
html += ` </tbody> html += ` </tbody>
@@ -1743,22 +1776,18 @@ document.addEventListener("DOMContentLoaded", () => {
}, },
); );
} else if (action === "clear-leaves") { } else if (action === "clear-leaves") {
await this.postApi( if (window.CONFIG.PEOPLE && window.CONFIG.PEOPLE.length > 0) {
"/modules/family-calendar/includes/api/manage-leaf.php", for (const person of window.CONFIG.PEOPLE) {
{ await this.postApi(
action: "bulk_delete_day_person", "/modules/family-calendar/includes/api/manage-leaf.php",
dates, {
person_id: window.CONFIG.ID_ALEX, action: "bulk_delete_day_person",
}, dates,
); person_id: person.id,
await this.postApi( },
"/modules/family-calendar/includes/api/manage-leaf.php", );
{ }
action: "bulk_delete_day_person", }
dates,
person_id: window.CONFIG.ID_LAIA,
},
);
} }
await this.refreshAllData(); await this.refreshAllData();
@@ -51,3 +51,4 @@
[2026-05-06T20:38:24+02:00] RAW INPUT: [{"date":"2026-05-08","type":"AVIS","duration":1,"person":"Carole"}] [2026-05-06T20:38:24+02:00] RAW INPUT: [{"date":"2026-05-08","type":"AVIS","duration":1,"person":"Carole"}]
[2026-05-06T20:43:43+02:00] RAW INPUT: [{"date":"2026-05-06","type":"AVIS","duration":1,"person":"Carole"}] [2026-05-06T20:43:43+02:00] RAW INPUT: [{"date":"2026-05-06","type":"AVIS","duration":1,"person":"Carole"}]
[2026-05-06T20:44:36+02:00] RAW INPUT: [{"date":"2026-05-06","type":"PEP_SICK","duration":1,"person":"Carole"}] [2026-05-06T20:44:36+02:00] RAW INPUT: [{"date":"2026-05-06","type":"PEP_SICK","duration":1,"person":"Carole"}]
[2026-05-20T16:40:17+02:00] RAW INPUT: [{"date":"2026-05-19","type":"OFF_CAROLE","duration":1,"person":"Carole"}]
+17 -1
View File
@@ -259,7 +259,23 @@ if ($action === 'maintenances') {
$photo = handleUpload('invoice_photo', $UPLOAD_DIR); $d = $_POST ?: gBody(); $photo = handleUpload('invoice_photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
if (!($d['vehicle_id'] ?? null)) gErr('vehicle_id manquant'); if (!($d['vehicle_id'] ?? null)) gErr('vehicle_id manquant');
$stmt = $pdo->prepare("INSERT INTO pf_maintenances (vehicle_id,type,description,date,km,cost,mechanic,garage_name,next_km,next_date,invoice_photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); $stmt = $pdo->prepare("INSERT INTO pf_maintenances (vehicle_id,type,description,date,km,cost,mechanic,garage_name,next_km,next_date,invoice_photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->execute([$d['vehicle_id'], $d['type']??'', $d['description']??null, $d['date']??date('Y-m-d'), $d['km']??null, $d['cost']??0, $d['mechanic']??null, $d['garage_name']??null, $d['next_km']??null, $d['next_date']??null, $photo, $d['notes']??null]); $nullInt = fn($v) => ($v ?? '') !== '' ? (int)$v : null;
$nullFloat = fn($v) => ($v ?? '') !== '' ? (float)$v : null;
$nullStr = fn($v) => ($v ?? '') !== '' ? $v : null;
$stmt->execute([
$d['vehicle_id'],
$d['type'] ?? '',
$nullStr($d['description'] ?? null),
$d['date'] ?? date('Y-m-d'),
$nullInt($d['km'] ?? null),
$nullFloat($d['cost'] ?? null),
$nullStr($d['mechanic'] ?? null),
$nullStr($d['garage_name'] ?? null),
$nullInt($d['next_km'] ?? null),
$nullStr($d['next_date'] ?? null),
$photo,
$nullStr($d['notes'] ?? null),
]);
$mid = $pdo->lastInsertId(); $mid = $pdo->lastInsertId();
if (!empty($d['km'])) { $pdo->prepare("UPDATE pf_vehicles SET current_km = GREATEST(current_km, ?), updated_at = NOW() WHERE id = ?")->execute([$d['km'], $d['vehicle_id']]); } if (!empty($d['km'])) { $pdo->prepare("UPDATE pf_vehicles SET current_km = GREATEST(current_km, ?), updated_at = NOW() WHERE id = ?")->execute([$d['km'], $d['vehicle_id']]); }
gOk(['id' => $mid]); gOk(['id' => $mid]);
+7
View File
@@ -1398,3 +1398,10 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
#tripMap { #tripMap {
touch-action: none; touch-action: none;
} }
/* --- Toll cost estimator panel --- */
.hol-cost-stat { background: var(--bg-page, #f8fafc); border: 1px solid var(--border-light, #e2e8f0); border-radius: 10px; padding: .75rem 1rem; text-align: center; }
.hol-cost-stat-val { font-size: 1.2rem; font-weight: 700; color: var(--text-main, #0f172a); }
.hol-cost-stat-label { font-size: .72rem; color: var(--text-muted, #64748b); margin-top: 2px; text-transform: uppercase; letter-spacing: .04em; }
.hol-cost-total { border-color: var(--primary, #4361ee); }
.hol-cost-total .hol-cost-stat-val { color: var(--primary, #4361ee); }
+73
View File
@@ -261,6 +261,22 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
</div> </div>
</div> </div>
<?php if (count($mapPoints) >= 2): ?>
<div class="hol-summary-card" id="hol-cost-panel" style="margin-top:24px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:16px;">
<h3 style="margin:0; display:flex; align-items:center; gap:8px;">🚗 Coût du trajet</h3>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<label style="font-size:.82rem; color:var(--text-muted);">Conso (L/100km)</label>
<input type="number" id="fuel-l100" value="7" min="3" max="30" step="0.5" style="width:65px; padding:.3rem .5rem; border:1px solid var(--border-light); border-radius:8px; font-size:.85rem;">
<label style="font-size:.82rem; color:var(--text-muted);">Prix carburant (€/L)</label>
<input type="number" id="fuel-price" value="1.85" min="1" max="4" step="0.01" style="width:65px; padding:.3rem .5rem; border:1px solid var(--border-light); border-radius:8px; font-size:.85rem;">
<button onclick="estimateTripCost()" class="pf-btn pf-btn-small">Calculer</button>
</div>
</div>
<div id="hol-cost-result" style="color:var(--text-muted); font-size:.9rem;">Cliquez sur Calculer pour estimer le coût du trajet.</div>
</div>
<?php endif; ?>
<?php if (!empty($holiday['notes'])): ?> <?php if (!empty($holiday['notes'])): ?>
<div class="hol-summary-card" style="margin-top: 24px; padding: 25px; border-left: 5px solid #f59e0b;"> <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;"> <h3 style="margin: 0 0 15px 0; font-size: 1.2rem; color: #0f172a; display: flex; align-items: center; gap: 8px;">
@@ -415,6 +431,63 @@ window.closePlanningModal = window.closePlanningModal || function() {
if(modal) modal.style.display = 'none'; if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll'); document.body.classList.remove('no-scroll');
}; };
async function estimateTripCost() {
const l100 = parseFloat(document.getElementById('fuel-l100').value) || 7;
const price = parseFloat(document.getElementById('fuel-price').value) || 1.85;
const result = document.getElementById('hol-cost-result');
result.innerHTML = '⏳ Calcul en cours…';
const stops = (window.MAP_POINTS || []).map(p => ({lat: p.lat, lng: p.lng, name: p.location_name}));
if (stops.length < 2) { result.innerHTML = 'Pas assez d\'étapes pour calculer.'; return; }
try {
const resp = await fetch('/modules/voyage/api.php?action=estimate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({stops, fuel_l100: l100, fuel_price: price})
});
const data = await resp.json();
if (!data.ok) { result.innerHTML = 'Erreur : ' + (data.error || 'inconnue'); return; }
let html = '<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:16px;">';
html += `<div class="hol-cost-stat"><div class="hol-cost-stat-val">${Math.round(data.total_km)} km</div><div class="hol-cost-stat-label">Distance totale</div></div>`;
html += `<div class="hol-cost-stat"><div class="hol-cost-stat-val">${data.total_toll.toFixed(2)} €</div><div class="hol-cost-stat-label">Péages estimés</div></div>`;
html += `<div class="hol-cost-stat"><div class="hol-cost-stat-val">${data.fuel_cost.toFixed(2)} €</div><div class="hol-cost-stat-label">Carburant</div></div>`;
html += `<div class="hol-cost-stat hol-cost-total"><div class="hol-cost-stat-val">${data.grand_total.toFixed(2)} €</div><div class="hol-cost-stat-label">Total aller</div></div>`;
html += '</div>';
if (data.segments && data.segments.length) {
html += '<div style="border-top:1px solid var(--border-light); padding-top:12px; margin-top:4px;">';
html += '<div style="font-size:.75rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--text-muted); margin-bottom:10px;">Détail du trajet</div>';
data.segments.forEach(s => {
html += `<div style="padding:8px 0; border-bottom:1px solid var(--border-light);">`;
html += `<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:4px;">
<span style="font-weight:600; font-size:.88rem;">📍 ${esc(s.from)} → ${esc(s.to)}</span>
<span style="font-size:.88rem; color:var(--primary);">péage : <strong>${s.toll.toFixed(2)} €</strong></span>
</div>`;
html += `<div style="font-size:.78rem; color:var(--text-muted);">🛣️ ${Math.round(s.distance_km)} km`;
if (s.entry_plaza && s.exit_plaza) {
html += ` · ${esc(s.entry_plaza)} → ${esc(s.exit_plaza)}`;
if (s.op) html += ` <span style="background:var(--bg-page); border:1px solid var(--border-light); border-radius:4px; padding:0 4px; font-size:.7rem;">${esc(s.op)}</span>`;
} else if (s.note) {
html += ` · <em>${esc(s.note)}</em>`;
}
html += '</div></div>';
});
html += '</div>';
}
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
result.innerHTML = html;
} catch(e) {
result.innerHTML = 'Erreur de connexion.';
console.error(e);
}
}
</script> </script>
<script src="/modules/holidays/holidays.js"></script> <script src="/modules/holidays/holidays.js"></script>
+19 -7
View File
@@ -15,6 +15,7 @@ require_once dirname(__DIR__, 2) . '/includes/db.php';
// ── Auto-create tables ──────────────────────────────────────────────────────── // ── Auto-create tables ────────────────────────────────────────────────────────
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists ( $pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL DEFAULT 'Ma liste', id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
color VARCHAR(20) DEFAULT NULL, list_type VARCHAR(50) DEFAULT NULL,
position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
@@ -48,6 +49,8 @@ $pdo->exec("CREATE TABLE IF NOT EXISTS pf_item_category_rules (
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN category_id INT DEFAULT NULL AFTER list_id"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN category_id INT DEFAULT NULL AFTER list_id"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_lists ADD COLUMN color VARCHAR(20) DEFAULT NULL AFTER name"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_lists ADD COLUMN list_type VARCHAR(50) DEFAULT NULL AFTER color"); } catch (\Exception $e) {}
// ── Seed categories if empty ────────────────────────────────────────────────── // ── Seed categories if empty ──────────────────────────────────────────────────
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) { if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) {
@@ -313,23 +316,32 @@ if ($action === 'set_category' && $method === 'POST') {
if ($action === 'lists') { if ($action === 'lists') {
if ($method === 'GET') { if ($method === 'GET') {
$firstId = liste_ensure_default($pdo); $firstId = liste_ensure_default($pdo);
$rows = $pdo->query("SELECT id, name, position FROM pf_lists ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC); $rows = $pdo->query("SELECT id, name, color, list_type, position FROM pf_lists ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['lists' => $rows, 'default_id' => $firstId]); echo json_encode(['lists' => $rows, 'default_id' => $firstId]);
exit; exit;
} }
if ($method === 'POST') { if ($method === 'POST') {
$name = mb_substr(trim($body['name'] ?? ''), 0, 100); $name = mb_substr(trim($body['name'] ?? ''), 0, 100);
$color = mb_substr(trim($body['color'] ?? ''), 0, 20) ?: null;
$list_type = mb_substr(trim($body['list_type'] ?? ''), 0, 50) ?: null;
if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; } if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
$pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn(); $pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn();
$pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?,?)")->execute([$name, $pos]); $pdo->prepare("INSERT INTO pf_lists (name, color, list_type, position) VALUES (?,?,?,?)")->execute([$name, $color, $list_type, $pos]);
echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'position' => $pos]); echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'color' => $color, 'list_type' => $list_type, 'position' => $pos]);
exit; exit;
} }
if ($method === 'PUT') { if ($method === 'PUT') {
$id = (int)($_GET['id'] ?? 0); $id = (int)($_GET['id'] ?? 0);
$name = mb_substr(trim($body['name'] ?? ''), 0, 100); $name = mb_substr(trim($body['name'] ?? ''), 0, 100);
$color = array_key_exists('color', $body) ? (mb_substr(trim($body['color'] ?? ''), 0, 20) ?: null) : false;
$list_type = array_key_exists('list_type', $body) ? (mb_substr(trim($body['list_type'] ?? ''), 0, 50) ?: null) : false;
if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; } if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; }
$pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([$name, $id]); $sets = ['name=?'];
$vals = [$name];
if ($color !== false) { $sets[] = 'color=?'; $vals[] = $color; }
if ($list_type !== false) { $sets[] = 'list_type=?'; $vals[] = $list_type; }
$vals[] = $id;
$pdo->prepare("UPDATE pf_lists SET " . implode(', ', $sets) . " WHERE id=?")->execute($vals);
echo json_encode(['ok' => true]); exit; echo json_encode(['ok' => true]); exit;
} }
if ($method === 'DELETE') { if ($method === 'DELETE') {
+136
View File
@@ -310,6 +310,135 @@
} }
.liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; } .liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; }
/* ── List modal ────────────────────────────────────────────────────────────── */
.liste-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.liste-modal {
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 14px;
width: 100%;
max-width: 420px;
box-shadow: 0 20px 60px rgba(0,0,0,.2);
}
.liste-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border, #dee2e6);
}
.liste-modal-header h3 { margin: 0; font-size: .95rem; font-weight: 700; color: var(--text, #212529); }
.liste-modal-close {
background: none;
border: none;
font-size: 1.3rem;
cursor: pointer;
color: var(--muted, #6c757d);
line-height: 1;
padding: 0 .25rem;
transition: color .15s;
}
.liste-modal-close:hover { color: var(--text, #212529); }
.liste-modal-body {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .85rem;
}
.liste-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: .5rem;
padding: .85rem 1.25rem;
border-top: 1px solid var(--border, #dee2e6);
}
.liste-modal-footer .btn-tool-danger { margin-right: auto; }
.liste-form-group { display: flex; flex-direction: column; gap: .3rem; }
.liste-form-label {
font-size: .73rem;
font-weight: 700;
color: var(--muted, #6c757d);
text-transform: uppercase;
letter-spacing: .04em;
}
.liste-modal-name, .liste-modal-type {
padding: .5rem .7rem;
border: 1px solid var(--border, #dee2e6);
border-radius: 8px;
background: var(--input-bg, #fff);
color: var(--text, #212529);
font-size: .875rem;
font-family: inherit;
width: 100%;
box-sizing: border-box;
transition: border-color .15s;
}
.liste-modal-name:focus, .liste-modal-type:focus {
outline: none;
border-color: var(--primary, #4361ee);
box-shadow: 0 0 0 3px rgba(67,97,238,.15);
}
.btn-tool-primary {
background: var(--primary, #4361ee);
color: #fff;
border-color: var(--primary, #4361ee);
}
.btn-tool-primary:hover { filter: brightness(1.1); background: var(--primary, #4361ee); }
/* ── Color picker ──────────────────────────────────────────────────────────── */
.liste-color-picker {
display: flex;
flex-wrap: wrap;
gap: .45rem;
}
.liste-color-swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 3px solid transparent;
background: var(--swatch, transparent);
cursor: pointer;
transition: transform .1s;
outline: 2px solid transparent;
outline-offset: 2px;
box-sizing: border-box;
}
.liste-color-swatch:hover { transform: scale(1.2); }
.liste-color-swatch.active {
outline-color: var(--swatch, var(--primary, #4361ee));
border-color: var(--card-bg, #fff);
}
.liste-color-swatch-none {
background: transparent !important;
border: 2px dashed var(--border, #dee2e6) !important;
}
.liste-color-swatch-none.active { outline-color: var(--primary, #4361ee); }
/* ── Tab type emoji ────────────────────────────────────────────────────────── */
.liste-tab-type { font-size: .85rem; line-height: 1; }
/* ── Category section headers ──────────────────────────────────────────────── */ /* ── Category section headers ──────────────────────────────────────────────── */
.liste-cat-section-header { .liste-cat-section-header {
@@ -429,6 +558,13 @@
[data-theme='dark'] .btn-tool:hover { background: #2a2a3e; } [data-theme='dark'] .btn-tool:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; } [data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; }
[data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; } [data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; }
[data-theme='dark'] .liste-modal { background: #1e1e2e; border-color: #3a3a4e; }
[data-theme='dark'] .liste-modal-header,
[data-theme='dark'] .liste-modal-footer { border-color: #3a3a4e; }
[data-theme='dark'] .liste-modal-header h3 { color: #cdd6f4; }
[data-theme='dark'] .liste-modal-name,
[data-theme='dark'] .liste-modal-type { background: #2a2a3e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .liste-color-swatch.active { border-color: #1e1e2e; }
[data-theme='dark'] .liste-cat-badge { border-color: #3a3a4e; } [data-theme='dark'] .liste-cat-badge { border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; } [data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; }
[data-theme='dark'] .liste-cat-section-count { background: #2a2a3e; color: #8892b0; } [data-theme='dark'] .liste-cat-section-count { background: #2a2a3e; color: #8892b0; }
+169 -66
View File
@@ -11,6 +11,35 @@ const state = {
catMap: {}, catMap: {},
}; };
// ── Constants ──────────────────────────────────────────────────────────────────
const LIST_COLORS = [
{ id: 'none', hex: null },
{ id: 'blue', hex: '#3b82f6' },
{ id: 'red', hex: '#ef4444' },
{ id: 'amber', hex: '#f59e0b' },
{ id: 'purple', hex: '#8b5cf6' },
{ id: 'pink', hex: '#ec4899' },
{ id: 'emerald', hex: '#10b981' },
{ id: 'orange', hex: '#f97316' },
{ id: 'cyan', hex: '#06b6d4' },
{ id: 'indigo', hex: '#6366f1' },
{ id: 'teal', hex: '#14b8a6' },
{ id: 'gray', hex: '#6b7280' },
];
const LIST_TYPES = [
{ value: '', label: '— Aucun —', emoji: '' },
{ value: 'courses', label: 'Courses', emoji: '🛒' },
{ value: 'todo', label: 'To-do', emoji: '✅' },
{ value: 'voyage', label: 'Voyage', emoji: '✈️' },
{ value: 'travail', label: 'Travail', emoji: '💼' },
{ value: 'maison', label: 'Maison', emoji: '🏠' },
{ value: 'sante', label: 'Santé', emoji: '💊' },
{ value: 'loisirs', label: 'Loisirs', emoji: '🎮' },
{ value: 'autre', label: 'Autre', emoji: '📋' },
];
// ── Utilities ────────────────────────────────────────────────────────────────── // ── Utilities ──────────────────────────────────────────────────────────────────
function esc(s) { function esc(s) {
@@ -42,6 +71,116 @@ async function api(action, method = 'GET', body = null, params = {}) {
return r.json(); return r.json();
} }
// ── List modal ─────────────────────────────────────────────────────────────────
let activeModal = null;
function openListModal(listId = null) {
if (activeModal) { activeModal.remove(); activeModal = null; }
const list = listId ? state.lists.find(l => l.id === listId) : null;
const isNew = !listId;
const backdrop = document.createElement('div');
backdrop.className = 'liste-modal-backdrop';
backdrop.innerHTML = `
<div class="liste-modal" role="dialog" aria-modal="true">
<div class="liste-modal-header">
<h3>${isNew ? 'Nouvelle liste' : 'Modifier la liste'}</h3>
<button class="liste-modal-close" aria-label="Fermer">×</button>
</div>
<div class="liste-modal-body">
<div class="liste-form-group">
<label class="liste-form-label">Nom</label>
<input class="liste-modal-name" type="text" maxlength="100"
value="${esc(list?.name ?? '')}" placeholder="Ma liste…" autocomplete="off">
</div>
<div class="liste-form-group">
<label class="liste-form-label">Couleur</label>
<div class="liste-color-picker">
${LIST_COLORS.map(c => {
const isActive = (c.hex === null && !list?.color) || (list?.color === c.hex);
const cls = 'liste-color-swatch' + (c.hex === null ? ' liste-color-swatch-none' : '') + (isActive ? ' active' : '');
const style = c.hex ? `style="--swatch:${c.hex}"` : '';
return `<button class="${cls}" data-color="${c.hex ?? ''}" ${style} title="${c.id}"></button>`;
}).join('')}
</div>
</div>
<div class="liste-form-group">
<label class="liste-form-label">Type de liste</label>
<select class="liste-modal-type">
${LIST_TYPES.map(t =>
`<option value="${esc(t.value)}" ${list?.list_type === t.value ? 'selected' : ''}>${t.emoji ? t.emoji + ' ' : ''}${esc(t.label)}</option>`
).join('')}
</select>
</div>
</div>
<div class="liste-modal-footer">
${!isNew && state.lists.length > 1
? `<button class="btn-tool btn-tool-danger liste-modal-delete">Supprimer</button>`
: ''}
<button class="btn-tool liste-modal-cancel">Annuler</button>
<button class="btn-tool btn-tool-primary liste-modal-save">Enregistrer</button>
</div>
</div>`;
document.body.appendChild(backdrop);
activeModal = backdrop;
const nameInput = backdrop.querySelector('.liste-modal-name');
nameInput.focus();
nameInput.setSelectionRange(nameInput.value.length, nameInput.value.length);
// Color swatches
backdrop.querySelectorAll('.liste-color-swatch').forEach(btn => {
btn.addEventListener('click', () => {
backdrop.querySelectorAll('.liste-color-swatch').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
const close = () => { backdrop.remove(); activeModal = null; };
backdrop.querySelector('.liste-modal-close').addEventListener('click', close);
backdrop.querySelector('.liste-modal-cancel').addEventListener('click', close);
backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
backdrop.querySelector('.liste-modal-delete')?.addEventListener('click', () => { close(); deleteList(listId); });
backdrop.querySelector('.liste-modal-save').addEventListener('click', () => saveListModal(backdrop, listId));
nameInput.addEventListener('keydown', e => {
if (e.key === 'Enter') saveListModal(backdrop, listId);
if (e.key === 'Escape') close();
});
}
async function saveListModal(backdrop, listId) {
const name = backdrop.querySelector('.liste-modal-name').value.trim();
if (!name) { backdrop.querySelector('.liste-modal-name').focus(); return; }
const activeSwatch = backdrop.querySelector('.liste-color-swatch.active');
const color = activeSwatch?.dataset.color || null;
const list_type = backdrop.querySelector('.liste-modal-type').value || null;
if (listId) {
const r = await api('lists', 'PUT', { name, color, list_type }, { id: listId });
if (r.ok) {
const list = state.lists.find(l => l.id === listId);
if (list) { list.name = name; list.color = color; list.list_type = list_type; }
backdrop.remove(); activeModal = null;
renderTabs();
toast('Liste mise à jour');
}
} else {
const r = await api('lists', 'POST', { name, color, list_type });
if (r.id) {
state.lists.push({ id: r.id, name, color: r.color, list_type: r.list_type, position: r.position });
state.currentListId = r.id;
backdrop.remove(); activeModal = null;
await loadItems();
renderTabs();
toast('Liste créée : ' + name);
}
}
}
// ── Categories ───────────────────────────────────────────────────────────────── // ── Categories ─────────────────────────────────────────────────────────────────
async function loadCategories() { async function loadCategories() {
@@ -104,49 +243,54 @@ function closeCategoryPicker() {
// ── Tabs ─────────────────────────────────────────────────────────────────────── // ── Tabs ───────────────────────────────────────────────────────────────────────
function typeEmoji(list_type) {
return LIST_TYPES.find(t => t.value === list_type)?.emoji || '';
}
function renderTabs() { function renderTabs() {
const container = document.getElementById('liste-tabs'); const container = document.getElementById('liste-tabs');
if (!container) return; if (!container) return;
container.innerHTML = ''; container.innerHTML = '';
state.lists.forEach(list => { state.lists.forEach(list => {
const isActive = list.id === state.currentListId;
const tab = document.createElement('div'); const tab = document.createElement('div');
tab.className = 'liste-tab' + (list.id === state.currentListId ? ' active' : ''); tab.className = 'liste-tab' + (isActive ? ' active' : '');
tab.dataset.id = list.id; tab.dataset.id = list.id;
if (list.id === state.currentListId) { // Apply custom color to entire tab
if (list.color) {
if (isActive) {
tab.style.cssText = `background:${list.color};border-color:${list.color};color:#fff`;
} else {
tab.style.cssText = `border-color:${list.color};color:${list.color}`;
}
}
const emoji = typeEmoji(list.list_type);
const prefix = emoji ? `<span class="liste-tab-type">${emoji}</span>` : '';
if (isActive) {
tab.innerHTML = ` tab.innerHTML = `
${prefix}
<span class="liste-tab-name">${esc(list.name)}</span> <span class="liste-tab-name">${esc(list.name)}</span>
<button class="liste-tab-btn liste-tab-rename" title="${esc(T.rename_list)}" data-id="${list.id}"></button> <button class="liste-tab-btn liste-tab-edit" title="Modifier" data-id="${list.id}"></button>
${state.lists.length > 1 ? `<button class="liste-tab-btn liste-tab-delete" title="Supprimer" data-id="${list.id}">×</button>` : ''}
`; `;
} else { } else {
tab.innerHTML = `<span class="liste-tab-name">${esc(list.name)}</span>`; tab.innerHTML = `${prefix}<span class="liste-tab-name">${esc(list.name)}</span>`;
tab.addEventListener('click', () => switchList(list.id)); tab.addEventListener('click', () => switchList(list.id));
} }
container.appendChild(tab); container.appendChild(tab);
}); });
container.querySelectorAll('.liste-tab-rename').forEach(btn => { container.querySelectorAll('.liste-tab-edit').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); }); btn.addEventListener('click', e => { e.stopPropagation(); openListModal(parseInt(btn.dataset.id)); });
});
container.querySelectorAll('.liste-tab-delete').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); });
}); });
} }
function startRename(listId) { // ── List management ─────────────────────────────────────────────────────────────
const list = state.lists.find(l => l.id === listId);
if (!list) return; document.getElementById('btn-add-list')?.addEventListener('click', () => openListModal(null));
const name = prompt(T.new_name || 'Nouveau nom :', list.name);
if (name === null || name.trim() === '') return;
api('lists', 'PUT', { name: name.trim() }, { id: listId }).then(r => {
if (r.ok) {
list.name = name.trim();
renderTabs();
toast(T.list_renamed || 'Liste renommée');
}
});
}
async function deleteList(listId) { async function deleteList(listId) {
if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return; if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return;
@@ -161,41 +305,6 @@ async function deleteList(listId) {
toast(T.list_deleted || 'Liste supprimée'); toast(T.list_deleted || 'Liste supprimée');
} }
// ── List management ─────────────────────────────────────────────────────────────
document.getElementById('btn-add-list')?.addEventListener('click', () => {
document.getElementById('liste-new-form')?.classList.remove('hidden');
document.getElementById('btn-add-list')?.classList.add('hidden');
document.getElementById('new-list-name')?.focus();
});
document.getElementById('btn-cancel-new-list')?.addEventListener('click', cancelNewList);
document.getElementById('btn-confirm-new-list')?.addEventListener('click', confirmNewList);
document.getElementById('new-list-name')?.addEventListener('keydown', e => {
if (e.key === 'Enter') confirmNewList();
if (e.key === 'Escape') cancelNewList();
});
function cancelNewList() {
document.getElementById('liste-new-form')?.classList.add('hidden');
document.getElementById('btn-add-list')?.classList.remove('hidden');
if (document.getElementById('new-list-name')) document.getElementById('new-list-name').value = '';
}
async function confirmNewList() {
const input = document.getElementById('new-list-name');
const name = input?.value.trim();
if (!name) return;
const r = await api('lists', 'POST', { name });
if (r.id) {
state.lists.push({ id: r.id, name: r.name, position: r.position });
state.currentListId = r.id;
cancelNewList();
await loadItems();
renderTabs();
toast((T.list_created || 'Liste créée') + ' : ' + r.name);
}
}
// ── Switch list ───────────────────────────────────────────────────────────────── // ── Switch list ─────────────────────────────────────────────────────────────────
async function switchList(listId) { async function switchList(listId) {
@@ -207,10 +316,7 @@ async function switchList(listId) {
// ── Items ─────────────────────────────────────────────────────────────────────── // ── Items ───────────────────────────────────────────────────────────────────────
async function loadItems() { async function loadItems() {
if (!state.currentListId) { if (!state.currentListId) { renderItems(); return; }
renderItems();
return;
}
const r = await api('items', 'GET', null, { list_id: state.currentListId }); const r = await api('items', 'GET', null, { list_id: state.currentListId });
state.items = r.items || []; state.items = r.items || [];
renderItems(); renderItems();
@@ -236,15 +342,12 @@ function renderItems() {
let html = ''; let html = '';
if (hasCategories && state.categories.length) { if (hasCategories && state.categories.length) {
// Group pending items by category
const groups = new Map(); const groups = new Map();
pending.forEach(item => { pending.forEach(item => {
const key = item.category_id ?? 0; const key = item.category_id ?? 0;
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item); groups.get(key).push(item);
}); });
// Render in category order, uncategorized last
const catIds = state.categories.map(c => parseInt(c.id)); const catIds = state.categories.map(c => parseInt(c.id));
const orderedKeys = catIds.filter(id => groups.has(id)); const orderedKeys = catIds.filter(id => groups.has(id));
if (groups.has(0)) orderedKeys.push(0); if (groups.has(0)) orderedKeys.push(0);
@@ -284,7 +387,7 @@ function rowHtml(item) {
const checked = item.in_cart ? 'checked' : ''; const checked = item.in_cart ? 'checked' : '';
const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row'; const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row';
const cat = item.category_id ? state.catMap[item.category_id] : null; const cat = item.category_id ? state.catMap[item.category_id] : null;
const badgeCls = 'liste-cat-badge' + (cat ? '' : ' liste-cat-badge-empty'); const badgeCls = 'liste-cat-badge' + (cat ? '' : ' liste-cat-badge-empty');
const badgeIcon = cat ? esc(cat.icon) : '🏷️'; const badgeIcon = cat ? esc(cat.icon) : '🏷️';
const badgeTip = cat ? esc(cat.name) : 'Non classé'; const badgeTip = cat ? esc(cat.name) : 'Non classé';
return `<div class="${cls}" data-id="${item.id}"> return `<div class="${cls}" data-id="${item.id}">
+243
View File
@@ -0,0 +1,243 @@
<?php
// modules/voyage/api.php
// Toll cost estimator API for HouseHub
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
$action = $_GET['action'] ?? '';
if ($action !== 'estimate') {
echo json_encode(['ok' => false, 'error' => 'Action inconnue']);
exit;
}
// --- Read POST body ---
$body = file_get_contents('php://input');
$params = json_decode($body, true);
if (!$params || empty($params['stops']) || count($params['stops']) < 2) {
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides (stops requis, min 2)']);
exit;
}
$stops = $params['stops'];
$fuelL100 = (float)($params['fuel_l100'] ?? 7);
$fuelPrice= (float)($params['fuel_price'] ?? 1.85);
// --- Load databases ---
$tollsPath = __DIR__ . '/data/tolls.json';
$gpsPath = __DIR__ . '/data/toll_gps.json';
if (!file_exists($tollsPath)) {
echo json_encode(['ok' => false, 'error' => 'Base de péages introuvable']);
exit;
}
$tollsRaw = json_decode(file_get_contents($tollsPath), true);
$tollData = $tollsRaw['data'] ?? [];
$gpsData = [];
if (file_exists($gpsPath)) {
$gpsData = json_decode(file_get_contents($gpsPath), true) ?? [];
}
// --- Build lookup index: [op][entry][exit] => c1_price ---
$tollIndex = [];
foreach ($tollData as $row) {
$op = $row['op'];
$e = $row['e'];
$x = $row['x'];
$c1 = (float)$row['c1'];
$tollIndex[$op][$e][$x] = $c1;
}
// --- Helper: great-circle distance (km) ---
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
$R = 6371.0;
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
return $R * 2 * atan2(sqrt($a), sqrt(1 - $a));
}
// --- Find plazas near a coordinate (within $maxKm) ---
// Returns array of [plaza_name => distance_km]
function plazasNear(array $gpsData, float $lat, float $lng, float $maxKm): array {
$result = [];
foreach ($gpsData as $name => $info) {
$d = haversine($lat, $lng, $info['lat'], $info['lng']);
if ($d <= $maxKm) {
$result[$name] = $d;
}
}
asort($result);
return $result;
}
// --- Look up toll price for (op, entry, exit) with fallback ---
function lookupToll(array $tollIndex, string $op, string $entry, string $exit): ?float {
// Direct lookup
if (isset($tollIndex[$op][$entry][$exit])) {
return $tollIndex[$op][$entry][$exit];
}
// Reversed
if (isset($tollIndex[$op][$exit][$entry])) {
return $tollIndex[$op][$exit][$entry];
}
return null;
}
// --- OSRM route distance ---
function getRouteDistanceKm(float $lat1, float $lng1, float $lat2, float $lng2): ?float {
$url = sprintf(
'https://router.project-osrm.org/route/v1/driving/%f,%f;%f,%f?overview=false',
$lng1, $lat1, $lng2, $lat2
);
$ctx = stream_context_create([
'http' => [
'timeout' => 10,
'header' => "User-Agent: HouseHub/1.0\r\n"
]
]);
$resp = @file_get_contents($url, false, $ctx);
if ($resp === false) return null;
$data = json_decode($resp, true);
if (!empty($data['routes'][0]['distance'])) {
return (float)$data['routes'][0]['distance'] / 1000.0;
}
return null;
}
// --- Main: process each consecutive stop pair ---
$segments = [];
$totalKm = 0.0;
$totalToll = 0.0;
for ($i = 0; $i < count($stops) - 1; $i++) {
$orig = $stops[$i];
$dest = $stops[$i + 1];
$oLat = (float)$orig['lat'];
$oLng = (float)$orig['lng'];
$dLat = (float)$dest['lat'];
$dLng = (float)$dest['lng'];
$fromName = $orig['name'] ?? "Étape " . ($i + 1);
$toName = $dest['name'] ?? "Étape " . ($i + 2);
// Distance via OSRM
$distKm = getRouteDistanceKm($oLat, $oLng, $dLat, $dLng);
if ($distKm === null) {
// Fallback: straight-line distance * 1.25
$distKm = haversine($oLat, $oLng, $dLat, $dLng) * 1.25;
}
$totalKm += $distKm;
// --- Find toll ---
$segToll = 0.0;
$entryPlaza = null;
$exitPlaza = null;
$tollNote = null;
if (!empty($gpsData)) {
// Find plazas near origin (80km) and destination (80km)
$nearOrig = plazasNear($gpsData, $oLat, $oLng, 80.0);
$nearDest = plazasNear($gpsData, $dLat, $dLng, 80.0);
$bestToll = null;
$bestDistSum = PHP_FLOAT_MAX;
$bestEntry = null;
$bestExit = null;
$bestOp = null;
// Try each operator
foreach ($tollIndex as $op => $opEntries) {
// Find candidate entry plazas (near origin, present in this operator)
$candidateEntries = [];
foreach ($nearOrig as $pName => $dist) {
if (isset($opEntries[$pName]) || isset(array_flip(array_keys($opEntries))[$pName])) {
// Plaza exists as entry in this operator
if (isset($opEntries[$pName])) {
$candidateEntries[$pName] = $dist;
}
}
// Also check if it's an exit plaza (reversed lookup later)
// Include any plaza from this operator that is within range
}
// Build set of all plazas for this operator
$opPlazas = [];
foreach ($opEntries as $entry => $exits) {
$opPlazas[$entry] = true;
foreach ($exits as $exit => $price) {
$opPlazas[$exit] = true;
}
}
// Filter nearOrig and nearDest to only plazas in this operator
$opNearOrig = array_intersect_key($nearOrig, $opPlazas);
$opNearDest = array_intersect_key($nearDest, $opPlazas);
if (empty($opNearOrig) || empty($opNearDest)) continue;
// Try best 5 entry and best 5 exit candidates
$topEntries = array_slice($opNearOrig, 0, 5, true);
$topExits = array_slice($opNearDest, 0, 5, true);
foreach ($topEntries as $ePlaza => $eDist) {
foreach ($topExits as $xPlaza => $xDist) {
if ($ePlaza === $xPlaza) continue;
$price = lookupToll($tollIndex, $op, $ePlaza, $xPlaza);
if ($price !== null) {
$distSum = $eDist + $xDist;
if ($distSum < $bestDistSum) {
$bestDistSum = $distSum;
$bestToll = $price;
$bestEntry = $ePlaza;
$bestExit = $xPlaza;
$bestOp = $op;
}
}
}
}
}
if ($bestToll !== null) {
$segToll = $bestToll;
$entryPlaza = $bestEntry;
$exitPlaza = $bestExit;
} else {
$tollNote = 'péages non estimés pour ce trajet';
}
} else {
$tollNote = 'base GPS des péages non disponible';
}
$totalToll += $segToll;
$seg = [
'from' => $fromName,
'to' => $toName,
'distance_km' => round($distKm, 1),
'toll' => round($segToll, 2),
];
if ($entryPlaza) $seg['entry_plaza'] = $entryPlaza;
if ($exitPlaza) $seg['exit_plaza'] = $exitPlaza;
if ($bestOp) $seg['op'] = $bestOp;
if ($tollNote) $seg['note'] = $tollNote;
$segments[] = $seg;
}
$fuelCost = round(($totalKm / 100.0) * $fuelL100 * $fuelPrice, 2);
$grandTotal = round($totalToll + $fuelCost, 2);
echo json_encode([
'ok' => true,
'segments' => $segments,
'total_km' => round($totalKm, 1),
'total_toll' => round($totalToll, 2),
'fuel_cost' => $fuelCost,
'grand_total' => $grandTotal,
], JSON_UNESCAPED_UNICODE);
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+263 -27
View File
@@ -1,10 +1,18 @@
-- HouseHub — Schéma MySQL -- HouseHub — Schéma MySQL (Modèle pour une base famille)
-- Créer la base : CREATE DATABASE househub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4; SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0; SET FOREIGN_KEY_CHECKS = 0;
-- ─── Utilisateurs ───────────────────────────────────────────────────────────── -- ─── Configuration du foyer ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT '',
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '', 'C');
-- ─── Utilisateurs (Legacy) ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_users ( CREATE TABLE IF NOT EXISTS pf_users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE, username VARCHAR(100) NOT NULL UNIQUE,
@@ -13,9 +21,13 @@ CREATE TABLE IF NOT EXISTS pf_users (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Personnes ──────────────────────────────────────────────────────────────── -- ─── Personnes ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_people ( CREATE TABLE `pf_people` (
id INT AUTO_INCREMENT PRIMARY KEY, `id` int(11) NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL `name` varchar(100) NOT NULL,
`user_id` int(11) DEFAULT NULL,
`role` varchar(50) DEFAULT NULL,
`color` varchar(7) DEFAULT '#0891b2',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Calendrier familial ────────────────────────────────────────────────────── -- ─── Calendrier familial ──────────────────────────────────────────────────────
@@ -91,7 +103,7 @@ CREATE TABLE IF NOT EXISTS pf_budget_items (
CREATE TABLE IF NOT EXISTS pf_expenses ( CREATE TABLE IF NOT EXISTS pf_expenses (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
date_exp DATE NOT NULL, date_exp DATE NOT NULL,
gestion_month VARCHAR(7) NOT NULL, gestion_month VARCHAR(10) NOT NULL,
category VARCHAR(100), category VARCHAR(100),
label VARCHAR(255), label VARCHAR(255),
amount DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0,
@@ -101,26 +113,28 @@ CREATE TABLE IF NOT EXISTS pf_expenses (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_alloc_categories ( CREATE TABLE IF NOT EXISTS pf_alloc_categories (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
target DECIMAL(10,2) DEFAULT 0, target DECIMAL(10,2) DEFAULT 0.00,
holiday_id INT DEFAULT NULL, transfer_dest VARCHAR(50) DEFAULT NULL,
sort_order INT DEFAULT 0 holiday_id INT DEFAULT NULL,
sort_order INT DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_alloc_values ( CREATE TABLE IF NOT EXISTS pf_alloc_values (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
month_date VARCHAR(7) NOT NULL, month_date VARCHAR(10) NOT NULL,
cat_id INT NOT NULL, cat_id INT NOT NULL,
amount_alex DECIMAL(10,2) DEFAULT 0, person_id INT NOT NULL,
amount_laia DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0.00,
UNIQUE KEY uq_alloc (month_date, cat_id) UNIQUE KEY uq_alloc_person (month_date, cat_id, person_id),
FOREIGN KEY (person_id) REFERENCES pf_people(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_savings ( CREATE TABLE IF NOT EXISTS pf_savings (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
owner VARCHAR(50) NOT NULL, owner VARCHAR(50) NOT NULL,
month_date VARCHAR(7) NOT NULL, month_date VARCHAR(10) NOT NULL,
category VARCHAR(100) NOT NULL, category VARCHAR(100) NOT NULL,
amount DECIMAL(10,2) DEFAULT 0, amount DECIMAL(10,2) DEFAULT 0,
holiday_id INT DEFAULT NULL holiday_id INT DEFAULT NULL
@@ -147,9 +161,22 @@ CREATE TABLE IF NOT EXISTS pf_salary_config (
CREATE TABLE IF NOT EXISTS pf_import_rules ( CREATE TABLE IF NOT EXISTS pf_import_rules (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL UNIQUE, keyword VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL category VARCHAR(100) NOT NULL,
budget_item_id INT(11) NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_advances (
id int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
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()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_notes ( CREATE TABLE IF NOT EXISTS pf_notes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
note_type VARCHAR(100) NOT NULL, note_type VARCHAR(100) NOT NULL,
@@ -158,6 +185,29 @@ CREATE TABLE IF NOT EXISTS pf_notes (
UNIQUE KEY uq_note (note_type, reference_id) UNIQUE KEY uq_note (note_type, reference_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content LONGTEXT DEFAULT '',
tags VARCHAR(1000) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FULLTEXT KEY ft_notes (title, content, tags)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
note_id INT NOT NULL,
type ENUM('image','file','url') NOT NULL DEFAULT 'file',
filename VARCHAR(255) DEFAULT NULL,
original_name VARCHAR(255) DEFAULT NULL,
url TEXT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Voyages / Holidays ─────────────────────────────────────────────────────── -- ─── Voyages / Holidays ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_holidays ( CREATE TABLE IF NOT EXISTS pf_holidays (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -225,16 +275,153 @@ CREATE TABLE IF NOT EXISTS pf_gifts (
amount DECIMAL(8,2) DEFAULT 0 amount DECIMAL(8,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1; -- ─── Garage Manager ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_vehicles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
brand VARCHAR(100) NOT NULL,
model VARCHAR(100) NOT NULL,
year INT DEFAULT NULL,
license_plate VARCHAR(50) DEFAULT NULL,
vin VARCHAR(100) DEFAULT NULL,
fuel_type VARCHAR(50) DEFAULT 'Essence',
color VARCHAR(50) DEFAULT NULL,
purchase_date DATE DEFAULT NULL,
purchase_price DECIMAL(10,2) DEFAULT NULL,
current_km INT DEFAULT 0,
photo VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Données initiales ──────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS pf_maintenances (
-- Utilisateur admin (mot de passe : changeme → à modifier !) id INT AUTO_INCREMENT PRIMARY KEY,
INSERT IGNORE INTO pf_users (id, username, password_hash, display_name) vehicle_id INT NOT NULL,
VALUES (1, 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin'); type VARCHAR(100) NOT NULL,
description TEXT DEFAULT NULL,
date DATE NOT NULL,
km INT DEFAULT NULL,
cost DECIMAL(10,2) DEFAULT 0,
mechanic VARCHAR(100) DEFAULT NULL,
garage_name VARCHAR(100) DEFAULT NULL,
next_km INT DEFAULT NULL,
next_date DATE DEFAULT NULL,
invoice_photo VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Personnes (IDs fixes correspondant aux constantes dans config.php) CREATE TABLE IF NOT EXISTS pf_parts (
INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex'); id INT AUTO_INCREMENT PRIMARY KEY,
INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia'); vehicle_id INT DEFAULT NULL,
maintenance_id INT DEFAULT NULL,
brand VARCHAR(100) DEFAULT NULL,
reference VARCHAR(100) DEFAULT NULL,
name VARCHAR(255) NOT NULL,
category VARCHAR(100) DEFAULT 'Autre',
price DECIMAL(10,2) DEFAULT 0,
quantity INT DEFAULT 1,
unit VARCHAR(50) DEFAULT 'pièce',
supplier VARCHAR(100) DEFAULT NULL,
purchase_date DATE DEFAULT NULL,
photo VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_garage_documents (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NOT NULL,
maintenance_id INT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) DEFAULT NULL,
mime VARCHAR(120) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_gd_vehicle (vehicle_id),
KEY idx_gd_maint (maintenance_id),
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Todo ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_todo_lists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
color VARCHAR(20) DEFAULT '#3b82f6',
icon VARCHAR(10) DEFAULT '📋',
position INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_todos (
id INT AUTO_INCREMENT PRIMARY KEY,
list_id INT DEFAULT NULL,
title VARCHAR(500) NOT NULL,
notes TEXT DEFAULT NULL,
due_date DATE DEFAULT NULL,
due_time TIME DEFAULT NULL,
notified TINYINT(1) DEFAULT 0,
notified_date DATE DEFAULT NULL,
priority ENUM('none','low','medium','high') DEFAULT 'none',
done TINYINT(1) DEFAULT 0,
done_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Module Liste (Courses partagées) ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
color VARCHAR(20) DEFAULT NULL,
list_type VARCHAR(50) DEFAULT NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY,
list_id INT NOT NULL DEFAULT 1,
category_id INT DEFAULT NULL,
label VARCHAR(500) NOT NULL,
in_cart TINYINT(1) NOT NULL DEFAULT 0,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_items_list (list_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_grocery_history (
id INT AUTO_INCREMENT PRIMARY KEY,
label_hash CHAR(64) NOT NULL,
label_display VARCHAR(500) NOT NULL,
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_grocery_hist_hash (label_hash),
KEY idx_hist_last (last_used_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_list_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
icon VARCHAR(10) NOT NULL DEFAULT '🏷️',
name VARCHAR(100) NOT NULL,
position INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_item_category_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL,
category_id INT NOT NULL,
UNIQUE KEY uq_keyword (keyword)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Calendar iOS ───────────────────────────────────────────────────────────── -- ─── Calendar iOS ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_calendar_events ( CREATE TABLE IF NOT EXISTS pf_calendar_events (
@@ -264,8 +451,57 @@ CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
external_uid VARCHAR(255) NOT NULL, external_uid VARCHAR(255) NOT NULL,
external_etag VARCHAR(255) DEFAULT NULL, external_etag VARCHAR(255) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL, calendar_url VARCHAR(1024) DEFAULT NULL,
external_href VARCHAR(2048) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_calendar_event (calendar_event_id), UNIQUE KEY uq_calendar_event (calendar_event_id),
UNIQUE KEY uq_external_link (external_uid) UNIQUE KEY uq_external_link (external_uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── PrintVault ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_pv_models (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
category VARCHAR(100) DEFAULT 'Non classé',
tags VARCHAR(500) DEFAULT '',
file_type VARCHAR(20) NOT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_size INT DEFAULT 0,
dim_x DECIMAL(10,3) DEFAULT 0,
dim_y DECIMAL(10,3) DEFAULT 0,
dim_z DECIMAL(10,3) DEFAULT 0,
volume DECIMAL(10,3) DEFAULT 0,
gcode_time VARCHAR(50) DEFAULT '',
gcode_filament VARCHAR(50) DEFAULT '',
gcode_nozzle VARCHAR(20) DEFAULT '',
gcode_bed VARCHAR(20) DEFAULT '',
thumb VARCHAR(255) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_pv_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(20) DEFAULT '#8b5cf6'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_pv_categories (name, color) VALUES
('Non classé','#64748b'),('Déco','#ec4899'),('Fonctionnel','#3b82f6'),
('Mécanique','#f59e0b'),('Jouets','#10b981'),('Outils','#ef4444'),
('Architecture','#8b5cf6'),('Art','#06b6d4');
-- ─── Planka ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_planka_config (
id INT NOT NULL DEFAULT 1,
project_id VARCHAR(32),
admin_token TEXT,
token_expires_at DATETIME,
active_board_id VARCHAR(32),
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;
+44 -17
View File
@@ -69,13 +69,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
} }
// 🟢 ENRICHISSEMENT : Prise en compte de la couleur du profil
if ($action === 'update_profile') { if ($action === 'update_profile') {
$display_name = trim($_POST['display_name'] ?? ''); $display_name = trim($_POST['display_name'] ?? '');
$color = trim($_POST['color'] ?? '#0891b2');
if (!$display_name) { if (!$display_name) {
$error = "Le prénom ne peut pas être vide."; $error = "Le prénom ne peut pas être vide.";
} else { } else {
// MÀJ dans la base Meta (users)
$meta_pdo->prepare("UPDATE users SET display_name = ? WHERE id = ?") $meta_pdo->prepare("UPDATE users SET display_name = ? WHERE id = ?")
->execute([$display_name, $user_id]); ->execute([$display_name, $user_id]);
// MÀJ dans la base locale Foyer (pf_people) via le user_id mappé
require_once __DIR__ . '/includes/db.php';
if (isset($pdo)) {
$stmtPeopleColor = $pdo->prepare("UPDATE pf_people SET color = ? WHERE user_id = ?");
$stmtPeopleColor->execute([$color, $user_id]);
}
$_SESSION['user']['display_name'] = $display_name; $_SESSION['user']['display_name'] = $display_name;
$success = "Profil mis à jour."; $success = "Profil mis à jour.";
} }
@@ -129,7 +141,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$error = "Format non supporté (jpg, png, webp)."; $error = "Format non supporté (jpg, png, webp).";
} else { } else {
$dest = $upload_dir . 'home_bg_' . $family_id . '.' . $ext; $dest = $upload_dir . 'home_bg_' . $family_id . '.' . $ext;
// Supprimer anciens fichiers de ce family
foreach (glob($upload_dir . 'home_bg_' . $family_id . '.*') as $old) @unlink($old); foreach (glob($upload_dir . 'home_bg_' . $family_id . '.*') as $old) @unlink($old);
if (move_uploaded_file($file['tmp_name'], $dest)) { if (move_uploaded_file($file['tmp_name'], $dest)) {
$success = "Image d'accueil mise à jour."; $success = "Image d'accueil mise à jour.";
@@ -225,9 +236,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
// ─── Chargement données ─────────────────────────────────────────────────────── // ─── Chargement données ───────────────────────────────────────────────────────
$user = $meta_pdo->prepare("SELECT * FROM users WHERE id = ?");
$user->execute([$user_id]); // 1. On charge la base locale AVANT pour que son $user n'écrase pas le nôtre
$user = $user->fetch(); if ($family_id) {
require_once __DIR__ . '/includes/db.php';
}
// 2. Maintenant on peut charger ton profil en toute sécurité
$stmtUser = $meta_pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmtUser->execute([$user_id]);
$user = $stmtUser->fetch();
// 3. Récupération de la couleur actuelle depuis pf_people
$user_color = '#0891b2'; // Fallback par défaut
if ($family_id && isset($pdo)) {
$stmtColorFetch = $pdo->prepare("SELECT color FROM pf_people WHERE user_id = ?");
$stmtColorFetch->execute([$user_id]);
$fetched_color = $stmtColorFetch->fetchColumn();
if ($fetched_color) {
$user_color = $fetched_color;
}
}
$family = null; $family = null;
$members = []; $members = [];
@@ -276,7 +305,6 @@ require __DIR__ . '/header.php';
<div class="pf-alert pf-alert--error"> <?= htmlspecialchars($error) ?></div> <div class="pf-alert pf-alert--error"> <?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<!-- ── Langue ─────────────────────────────────────────────────────────── -->
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2">🌐 Langue / Language</h2> <h2 class="pf-card-h2">🌐 Langue / Language</h2>
<form method="post" class="pf-lang-form"> <form method="post" class="pf-lang-form">
@@ -303,7 +331,6 @@ require __DIR__ . '/header.php';
</form> </form>
</section> </section>
<!-- ── Modules ────────────────────────────────────────────────────────── -->
<?php if ($family_id): ?> <?php if ($family_id): ?>
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2 pf-card-h2--tight">🧩 Modules actifs</h2> <h2 class="pf-card-h2 pf-card-h2--tight">🧩 Modules actifs</h2>
@@ -413,7 +440,6 @@ require __DIR__ . '/header.php';
<?php endif; ?> <?php endif; ?>
</section> </section>
<!-- ── Fond page d'accueil ───────────────────────────────────────────── -->
<?php if ($family_id): <?php if ($family_id):
$bg_file = null; $bg_file = null;
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $f) { $bg_file = $f; break; } foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $f) { $bg_file = $f; break; }
@@ -452,15 +478,12 @@ require __DIR__ . '/header.php';
<?php endif; ?> <?php endif; ?>
<!-- ── Famille ──────────────────────────────────────────────────────────── --> <?php
<?php
$enabledMods = $_SESSION['enabled_modules'] ?? []; $enabledMods = $_SESSION['enabled_modules'] ?? [];
// On cible précisément qui a besoin de quoi
$showCurrency = count(array_intersect(['budget', 'holidays', 'gifts', 'garage'], $enabledMods)) > 0; $showCurrency = count(array_intersect(['budget', 'holidays', 'gifts', 'garage'], $enabledMods)) > 0;
$showZone = in_array('calendar', $enabledMods); $showZone = in_array('calendar', $enabledMods);
// Si au moins un des champs doit être affiché, on rend la section visible
if ($family_id && ($showCurrency || $showZone)): if ($family_id && ($showCurrency || $showZone)):
$currentCurrency = defined('CURRENCY') ? CURRENCY : '€'; $currentCurrency = defined('CURRENCY') ? CURRENCY : '€';
$currentZone = defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : 'C'; $currentZone = defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : 'C';
@@ -502,7 +525,6 @@ require __DIR__ . '/header.php';
<?php endif; ?> <?php endif; ?>
<!-- ── Profil ──────────────────────────────────────────────────────────── -->
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2">👤 Mon profil</h2> <h2 class="pf-card-h2">👤 Mon profil</h2>
<form method="post"> <form method="post">
@@ -516,11 +538,19 @@ require __DIR__ . '/header.php';
<label class="pf-label">Prénom / Pseudo affiché</label> <label class="pf-label">Prénom / Pseudo affiché</label>
<input type="text" name="display_name" class="pf-input" value="<?= htmlspecialchars($user['display_name']) ?>" required> <input type="text" name="display_name" class="pf-input" value="<?= htmlspecialchars($user['display_name']) ?>" required>
</div> </div>
<div class="pf-form-group">
<label class="pf-label" for="profile_color">Couleur thématique de mon profil</label>
<div class="pf-color-picker-group">
<input type="color" name="color" id="profile_color" class="pf-input-color" value="<?= htmlspecialchars($user_color) ?>">
<span class="pf-muted-note">Cette couleur sera utilisée pour vos grilles de budget, calendriers et indicateurs personnels.</span>
</div>
</div>
<button type="submit" class="pf-btn">Enregistrer</button> <button type="submit" class="pf-btn">Enregistrer</button>
</form> </form>
</section> </section>
<!-- ── Mot de passe ────────────────────────────────────────────────────── -->
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2">🔒 Changer le mot de passe</h2> <h2 class="pf-card-h2">🔒 Changer le mot de passe</h2>
<form method="post"> <form method="post">
@@ -543,7 +573,6 @@ require __DIR__ . '/header.php';
</section> </section>
<?php if ($family): ?> <?php if ($family): ?>
<!-- ── Espace familial ─────────────────────────────────────────────────── -->
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2">🏠 Mon espace familial</h2> <h2 class="pf-card-h2">🏠 Mon espace familial</h2>
@@ -559,7 +588,6 @@ require __DIR__ . '/header.php';
</div> </div>
</form> </form>
<!-- Code d'invitation -->
<div class="pf-dashed-panel"> <div class="pf-dashed-panel">
<p class="pf-muted-note pf-muted-note--tight">Code d'invitation — partagez-le pour inviter quelqu'un</p> <p class="pf-muted-note pf-muted-note--tight">Code d'invitation — partagez-le pour inviter quelqu'un</p>
<div class="pf-invite-row"> <div class="pf-invite-row">
@@ -582,7 +610,6 @@ require __DIR__ . '/header.php';
</p> </p>
</div> </div>
<!-- Membres -->
<h3 class="pf-members-heading">Membres (<?= count($members) ?>)</h3> <h3 class="pf-members-heading">Membres (<?= count($members) ?>)</h3>
<div class="pf-stack-sm"> <div class="pf-stack-sm">
<?php foreach ($members as $m): ?> <?php foreach ($members as $m): ?>
@@ -630,4 +657,4 @@ function copyCode() {
} }
</script> </script>
<?php require __DIR__ . '/footer.php'; ?> <?php require __DIR__ . '/footer.php'; ?>