Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
536f479423 | ||
|
|
685c649e7c | ||
|
|
074d8e12d2 | ||
|
|
f748330a1b | ||
|
|
ebdbee71db | ||
|
|
659b286967 | ||
|
|
ac8ebf7328 | ||
|
|
49d4ec7c35 | ||
|
|
c48a6ba116 | ||
|
|
20d6c9b7b0 | ||
|
|
d856d0a409 | ||
|
|
71ec8bd68a | ||
|
|
86ef3ccbf2 | ||
|
|
e7aa3b595b | ||
|
|
3e98de9c18 | ||
|
|
6b2f83b68c | ||
|
|
7e24ca7612 | ||
|
|
506cd64a44 | ||
|
|
041411b070 | ||
|
|
fa6521f8f5 | ||
|
|
5618448602 | ||
|
|
9d22dc29b4 | ||
|
|
b3a4614c59 | ||
|
|
18740c5e1b | ||
|
|
854c6388ba | ||
|
|
1e090cf9ca | ||
|
|
ff83320f9a | ||
|
|
557a1451de | ||
|
|
03a97df8f1 | ||
|
|
ad5a845f18 | ||
|
|
142d4612a9 | ||
|
|
2e30569675 | ||
|
|
f5c5320189 | ||
|
|
5b4f4c68b2 | ||
|
|
aad528d15b | ||
|
|
89d8ad114b | ||
|
|
f36bfa828e | ||
|
|
f9aa368768 | ||
|
|
e73524b2ae | ||
|
|
96df90d372 | ||
|
|
d6e95c1b2a | ||
|
|
16b08dd03b | ||
|
|
6459c225ac | ||
|
|
429a4ccb66 | ||
|
|
cd79bbb71d | ||
|
|
ecac9479d5 | ||
|
|
429a964727 | ||
|
|
70462aa78b | ||
|
|
afa6587eb3 | ||
|
|
c26a369bb6 | ||
|
|
305b58653d | ||
|
|
8e3e7fe3d8 | ||
|
|
2c8bbbb3e1 | ||
|
|
0ff3c42d35 | ||
|
|
ea04831451 | ||
|
|
d9ae5308ea | ||
|
|
2c5fb9da6b | ||
|
|
751082308a | ||
|
|
b44688a654 | ||
|
|
f8e2ad67ee | ||
|
|
6aab112ecb | ||
|
|
6e409fb3a1 | ||
|
|
535f1b7dcf | ||
|
|
4130b03a68 | ||
|
|
315e1b5fb1 | ||
|
|
b1d3db4892 | ||
|
|
99ed0e31de | ||
|
|
a1e7bc62fd | ||
|
|
3675f0c01a | ||
|
|
836533fcf6 | ||
|
|
5fe9046a63 | ||
|
|
5b70cd20e6 | ||
|
|
ed2d5a7c9e | ||
|
|
6bbee271e3 | ||
|
|
7b294f300a | ||
|
|
3e4374392a | ||
|
|
ab97af0ea3 | ||
|
|
cbb8f93435 | ||
|
|
16bab06b71 | ||
|
|
969bd5c2bf | ||
|
|
e770ccf22b | ||
|
|
5828d1796d | ||
|
|
0db14560a5 | ||
|
|
7d62aa02be | ||
|
|
285273108c | ||
|
|
3516225e56 | ||
|
|
987ce43e52 | ||
|
|
8176fc4de0 | ||
|
|
1a9d5d5d18 | ||
|
|
0ca3eef7c3 | ||
|
|
7a697213d4 | ||
|
|
00db9dedfd | ||
|
|
f2b1c05356 | ||
|
|
237de1e529 | ||
|
|
811d186b46 | ||
|
|
b756517ab5 | ||
|
|
1ee0ec820c | ||
|
|
99e135ec74 | ||
|
|
7272772c9f | ||
|
|
9aa61c85df | ||
|
|
050d8eda8b | ||
|
|
3e58b00c1f | ||
|
|
f2c2a43d22 | ||
|
|
b2a3e34bbe | ||
|
|
f62f724046 | ||
|
|
3a2c8eb5cf | ||
|
|
9ca290c8ac | ||
|
|
e6de684f28 | ||
|
|
a2e0061dde | ||
|
|
d9d9587ded | ||
|
|
8cb9d37154 | ||
|
|
c62e6ef52d | ||
|
|
5b74c6da04 | ||
|
|
d660d2cc7a | ||
|
|
912734d2cc | ||
|
|
1c274289bf | ||
|
|
63833abbc7 | ||
|
|
b3f320975b | ||
|
|
fd53b33e9f | ||
|
|
f68c2b7259 | ||
|
|
cfc115665b | ||
|
|
401e98e9ee | ||
|
|
c3229cbf11 | ||
|
|
9ec78ba77e | ||
|
|
606dcbf3d7 | ||
|
|
5d5ccf406d | ||
|
|
c758337ff3 | ||
|
|
2016407a43 | ||
|
|
d913f3c0eb | ||
|
|
6ef3b26153 | ||
|
|
7790375c01 | ||
|
|
b97cba6e75 | ||
|
|
5a81c1e9f0 | ||
|
|
3e344f4c1c | ||
|
|
19ab75f7df | ||
|
|
35c3ef020d | ||
|
|
1ddd080c16 | ||
|
|
c25d784471 | ||
|
|
d9d310da37 | ||
|
|
1e43f4bdc1 | ||
|
|
63d71ab7e4 | ||
|
|
87eb3189ec | ||
|
|
c94af542ff | ||
|
|
aff2d24a34 | ||
|
|
b87146c9cf | ||
|
|
20989d608a | ||
|
|
89299613cb | ||
|
|
8584bf8c1f | ||
|
|
b9403ef86b | ||
|
|
19031548e1 | ||
|
|
87035ed3b6 | ||
|
|
b338d7550a | ||
|
|
5ac4e51efb | ||
|
|
7cacf4bdf5 | ||
|
|
eb34657473 | ||
|
|
a443ec389b | ||
|
|
52c8e82221 | ||
|
|
997d3872ef | ||
|
|
62eabfb5ee | ||
|
|
f6de4918bd | ||
|
|
3077db5b98 | ||
|
|
4b2ab275bc | ||
|
|
a0f5f04274 | ||
|
|
b80a949701 | ||
|
|
d25fa4b8dc | ||
|
|
d856cd8189 | ||
|
|
472d6ac7fd | ||
|
|
6acda4ab25 | ||
|
|
94d85dc8da | ||
|
|
5bfc8efae4 | ||
|
|
0494554c30 | ||
|
|
84db4b6619 | ||
|
|
634e4647da | ||
|
|
3f7ed9c2f1 | ||
|
|
29f341b052 | ||
|
|
8792e44f69 | ||
|
|
e099fb80cd | ||
|
|
8a6abe5f8e | ||
|
|
2b2150870c | ||
|
|
31f03b7182 | ||
|
|
f2af3541f6 | ||
|
|
ab1374472d | ||
|
|
55a2f8a730 | ||
|
|
3bfd25d566 | ||
|
|
ff04458a59 | ||
|
|
97389ab0ad | ||
|
|
902f093ced | ||
|
|
954c07a838 | ||
|
|
be2edcce82 | ||
|
|
54edb5ed58 | ||
|
|
9c5620b3e3 | ||
|
|
4763e6a947 | ||
|
|
7b5c0be72b | ||
|
|
a2870f2421 | ||
|
|
5b050a7c24 | ||
|
|
2cb62d7ead | ||
|
|
6ce49bffc0 | ||
|
|
cc937c544c | ||
|
|
281a37c401 | ||
|
|
cdd2abaa96 | ||
|
|
1babf4c52e | ||
|
|
8c4c402f5b | ||
|
|
2087cfb945 | ||
|
|
9070390770 | ||
|
|
daff54784f | ||
|
|
e42808149d | ||
|
|
9cca683722 | ||
|
|
377c404d49 | ||
|
|
10b9f1bb0b | ||
|
|
12f9e27845 | ||
|
|
fe39eb8f2c | ||
|
|
0db6a71fd7 | ||
|
|
30599de125 | ||
|
|
0412b60b97 | ||
|
|
6d4ba85d00 | ||
|
|
feb2f158dd | ||
|
|
c4b80c3650 | ||
|
|
25b1c27ba9 | ||
|
|
6d5982d923 | ||
|
|
97a8fb546e | ||
|
|
7df7144147 | ||
|
|
2882c682f0 | ||
|
|
eff1628115 | ||
|
|
d03f6bad80 | ||
|
|
c2aa1e9f7b | ||
|
|
e07c57ea56 | ||
|
|
b425ec78aa | ||
|
|
ebfa0c99f3 | ||
|
|
73676bc20a | ||
|
|
3930f5f1be | ||
|
|
ca9c774422 | ||
|
|
a403628c07 | ||
|
|
7096391561 | ||
|
|
cdeff93fbd | ||
|
|
3f842e091a | ||
|
|
aeb33930ab | ||
|
|
106d6c99de | ||
|
|
3a8f58ef39 | ||
|
|
28b8a6d49a | ||
|
|
1f8b28cf8e | ||
|
|
9424b44fd9 | ||
|
|
39b8b64116 | ||
|
|
bc82a42517 | ||
|
|
71ce5ce008 | ||
|
|
7501339b39 | ||
|
|
3db060bc0d | ||
|
|
5859221fca | ||
|
|
9b2cc83512 | ||
|
|
faaf89e1ab | ||
|
|
4c5646766a | ||
|
|
5b510a047f | ||
|
|
27564c8e9e | ||
|
|
7060a43859 | ||
|
|
730c2a363e | ||
|
|
5ae38e1e4b | ||
|
|
e22a7e183c | ||
|
|
80d39ab8b9 | ||
|
|
695234d182 | ||
|
|
8e3c90524e | ||
|
|
1f16135ba5 | ||
|
|
7c9ca1b705 | ||
|
|
6e51a9215c | ||
|
|
36b54ea953 | ||
|
|
6cbbfc4811 | ||
|
|
cb2d0b5a06 | ||
|
|
a90c1bc11b | ||
|
|
fde728c888 | ||
|
|
910d21da5a | ||
|
|
c01d793a1e | ||
|
|
0e6c3edbc3 | ||
|
|
4731efe615 | ||
|
|
92424bdb51 | ||
|
|
9a3d646ca5 | ||
|
|
71db01d159 | ||
|
|
16e3eb0890 | ||
|
|
293bfbb0f1 | ||
|
|
5eb1aec8ff | ||
|
|
54c8503ed1 | ||
|
|
44e5241ee2 | ||
|
|
d557636eee | ||
|
|
43bcdc83a1 | ||
|
|
f5822f2694 | ||
|
|
170f98d8b8 | ||
|
|
94cde4ae34 | ||
|
|
e248eee882 | ||
|
|
fbc95815d8 | ||
|
|
c5b89f2bf4 | ||
|
|
bc622f0b04 | ||
|
|
564df35641 | ||
|
|
a8cd460cd6 | ||
|
|
7e149d0c1a | ||
|
|
c3ba14c8b0 | ||
|
|
6c8a7ab1b4 | ||
|
|
b22348ebba | ||
|
|
3f01cbd8aa | ||
|
|
2df2fd0b2e | ||
|
|
796d3b3fcc | ||
|
|
b7533a4d05 | ||
|
|
eee36e4034 | ||
|
|
abe85cb5d3 | ||
|
|
907a17a2b9 | ||
|
|
ab46c105f7 | ||
|
|
3b895f3f0c | ||
|
|
a70cfff122 | ||
|
|
e615156347 | ||
|
|
9beb6e1b43 | ||
|
|
c6793e2473 | ||
|
|
e86816ac7e | ||
|
|
a08dc62ba6 | ||
|
|
0de792781a | ||
|
|
4e4177d8d0 | ||
|
|
128a738fae |
@@ -1,20 +1,35 @@
|
|||||||
name: Deploy to Pacha Family
|
name: Deploy HouseHub
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, staging-perco, staging-fefe]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Deploy via SSH
|
||||||
uses: actions/checkout@v4
|
uses: appleboy/ssh-action@v1.0.0
|
||||||
|
|
||||||
- name: Deploy to Production
|
|
||||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.4
|
|
||||||
with:
|
with:
|
||||||
server: ${{ secrets.FTP_SERVER_PERCOLOUCO }}
|
host: 192.168.1.29
|
||||||
username: ${{ secrets.FTP_USERNAME_PERCOLOUCO }}
|
username: perco
|
||||||
password: ${{ secrets.FTP_PASSWORD_PERCOLOUCO }}
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
local-dir: ./
|
script: |
|
||||||
server-dir: /www/pachafamily/ # ← Production reste ici
|
BRANCH="${{ github.ref_name }}"
|
||||||
|
case "$BRANCH" in
|
||||||
|
main)
|
||||||
|
cd /opt/container/househub
|
||||||
|
git pull
|
||||||
|
echo "✓ Production mise à jour"
|
||||||
|
;;
|
||||||
|
staging-perco)
|
||||||
|
cd /opt/container/househub-perco
|
||||||
|
git pull origin staging-perco
|
||||||
|
echo "✓ Staging Perco mis à jour"
|
||||||
|
;;
|
||||||
|
staging-fefe)
|
||||||
|
cd /opt/container/househub-fefe
|
||||||
|
git pull origin staging-fefe
|
||||||
|
echo "✓ Staging Fefe mis à jour"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
househub_db_data
|
||||||
|
.env
|
||||||
|
data/.hh_app_secret
|
||||||
|
extract_source.php
|
||||||
|
.htaccess
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM php:8.2-apache
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libpng-dev libjpeg-dev libwebp-dev libzip-dev libonig-dev \
|
||||||
|
&& docker-php-ext-install pdo pdo_mysql mysqli mbstring zip \
|
||||||
|
&& a2enmod rewrite \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY . /var/www/html/
|
||||||
|
|
||||||
|
RUN chown -R www-data:www-data /var/www/html
|
||||||
|
|
||||||
|
COPY docker/apache.conf /etc/apache2/sites-available/000-default.conf
|
||||||
|
COPY docker/php.ini /usr/local/etc/php/conf.d/uploads.ini
|
||||||
@@ -1 +1,193 @@
|
|||||||
pachafamily
|
# 🦙 HouseHub OS
|
||||||
|
|
||||||
|
Système de gestion familiale sur-mesure — Budget, Calendrier, Voyages, Tâches, Courses, Notes, Impression 3D et plus.
|
||||||
|
|
||||||
|
Stack : PHP 8.2 natif · Vanilla JS · MariaDB 11 · Docker · Traefik
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Déploiement (Docker)
|
||||||
|
|
||||||
|
**Prérequis** : Docker, Docker Compose, Traefik sur le réseau `proxy`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Cloner le repo
|
||||||
|
git clone http://<gitea>/perco/HouseHub.git && cd HouseHub
|
||||||
|
|
||||||
|
# 2. Créer le fichier d'environnement
|
||||||
|
cp .env.example .env
|
||||||
|
# Éditer .env pour changer les mots de passe
|
||||||
|
|
||||||
|
# 3. Lancer
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 4. Accéder à l'app
|
||||||
|
# https://househub.nas.percolouco.com
|
||||||
|
```
|
||||||
|
|
||||||
|
La base de données meta (`househub_meta`) et les permissions sont **initialisées automatiquement** au premier démarrage via `docker/init/00-setup.sql`.
|
||||||
|
|
||||||
|
Aucun compte par défaut — le premier utilisateur s'inscrit via `/register.php` et choisit de créer un espace familial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 Gestion multi-tenant
|
||||||
|
|
||||||
|
HouseHub supporte plusieurs familles indépendantes sur la même instance.
|
||||||
|
|
||||||
|
| URL | Description |
|
||||||
|
| --------------- | ------------------------------------------------------------------- |
|
||||||
|
| `/register.php` | Créer un compte + nouvel espace, ou rejoindre via code d'invitation |
|
||||||
|
| `/login.php` | Connexion |
|
||||||
|
| `/settings.php` | Profil, mot de passe, langue, modules actifs, code d'invitation |
|
||||||
|
| `/admin/` | Panneau d'administration (admin uniquement) |
|
||||||
|
|
||||||
|
### Inviter quelqu'un dans son espace
|
||||||
|
|
||||||
|
1. Aller sur `/settings.php` → copier le code d'invitation
|
||||||
|
2. La personne va sur `/register.php` → onglet "Rejoindre" → coller le code
|
||||||
|
3. Les deux comptes partagent le même environnement de données
|
||||||
|
|
||||||
|
### Rôle admin
|
||||||
|
|
||||||
|
Le premier utilisateur inscrit doit être promu admin manuellement :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE househub_meta.users SET is_admin = 1 WHERE username = 'ton_username';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Modules
|
||||||
|
|
||||||
|
Tous les modules sont activables/désactivables par famille depuis `/settings.php`.
|
||||||
|
|
||||||
|
| Module | Route | Description |
|
||||||
|
| ----------------- | ---------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| 💰 Budget | `/budget.php` | Suivi mensuel, prévisionnel, épargne, import CSV bancaire |
|
||||||
|
| 📅 Calendrier | `/family-calendar.php` | Congés (CP/JRA/JA), modes de garde, planning hebdo, vacances scolaires |
|
||||||
|
| 🗓️ Calendrier iOS | `/calendar-ios.php` | Synchronisation CalDAV avec iPhone/iPad |
|
||||||
|
| 🏖️ Voyages | `/holidays.php` | Roadtrips, cartographie Leaflet, itinéraires OSRM, météo Open-Meteo |
|
||||||
|
| 🎁 Cadeaux | `/gift-list.php` | Listes Noël / anniversaires, intégration Tricount |
|
||||||
|
| 🚗 Garage | `/garage.php` | Véhicules, historique entretiens, pièces détachées |
|
||||||
|
| 🛒 Courses | `/groceries.php` | Liste de courses partagée en temps réel |
|
||||||
|
| ✅ Todo | `/todo.php` | Listes de tâches avec rappels quotidiens via Discord webhook |
|
||||||
|
| 📝 Mémo | `/memo.php` | Notes personnelles / familiales avec éditeur riche |
|
||||||
|
| 🖨️ PrintVault | `/printvault.php` | Gestionnaire de fichiers d'impression 3D (STL, 3MF, GCode) avec viewer Three.js |
|
||||||
|
| 📋 Planka | `/planka.php` | Kanban natif connecté à Planka — boards, listes, cartes, drag & drop, checklist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Planka
|
||||||
|
|
||||||
|
Module Kanban natif connecté à l'instance Planka du NAS — affichage et gestion directement dans HouseHub sans iframe.
|
||||||
|
|
||||||
|
- **Boards** : sélecteur de boards du projet Planka lié à la famille
|
||||||
|
- **Colonnes** : listes Planka affichées en colonnes scrollables
|
||||||
|
- **Cartes** : labels colorés, date d'échéance, checklist
|
||||||
|
- **Actions** : créer, modifier, supprimer des cartes ; drag & drop entre colonnes ; cocher les tâches
|
||||||
|
- **Par famille** : chaque espace HouseHub peut pointer vers son propre projet Planka
|
||||||
|
- **Paramètres** : bouton ⚙️ pour configurer le `project_id` Planka
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖨️ PrintVault
|
||||||
|
|
||||||
|
Module de gestion de fichiers d'impression 3D entièrement intégré à HouseHub.
|
||||||
|
|
||||||
|
- **Formats supportés** : STL (binaire/ASCII), 3MF, GCode
|
||||||
|
- **Viewer 3D** : Three.js (STL/3MF) et visualisation des trajectoires GCode avec dégradé de couleur par hauteur Z
|
||||||
|
- **Métadonnées GCode** : temps d'impression estimé, longueur filament, température buse/plateau
|
||||||
|
- **Stockage local** : fichiers sur le serveur, métadonnées en MariaDB (`pf_pv_*`)
|
||||||
|
- **Limite upload** : 300 Mo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Todo — Rappels Discord
|
||||||
|
|
||||||
|
Le module Todo supporte des rappels quotidiens via un **webhook Discord** :
|
||||||
|
|
||||||
|
1. Aller sur `/todo.php` → ⚙️ → coller l'URL du webhook Discord
|
||||||
|
2. Configurer le cron sur l'hôte :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
* * * * * docker exec househub php /var/www/html/cron/todo_notify.php >> /home/perco/househub_todo_cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Le cron tourne chaque minute et envoie une notification Discord à l'heure exacte configurée sur chaque tâche.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Stack technique
|
||||||
|
|
||||||
|
| Composant | Technologie |
|
||||||
|
| -------------------- | ---------------------------------------- |
|
||||||
|
| Backend | PHP 8.2 (Apache) |
|
||||||
|
| Base de données | MariaDB 11 |
|
||||||
|
| Frontend | Vanilla JS, CSS custom (dark mode natif) |
|
||||||
|
| Viewer 3D | Three.js (ES modules via importmap CDN) |
|
||||||
|
| Cartographie | Leaflet + OSRM |
|
||||||
|
| Conteneurisation | Docker + Docker Compose |
|
||||||
|
| Reverse proxy | Traefik |
|
||||||
|
| Internationalisation | FR / CA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Mises à jour & Migrations SQL (Multi-Tenant)
|
||||||
|
|
||||||
|
L'architecture de HouseHub isole chaque foyer dans sa propre base de données (`househub_f1`, `househub_f2`, etc.). Lors de l'ajout de nouvelles fonctionnalités impliquant des changements de structure SQL, il faut suivre une double procédure :
|
||||||
|
|
||||||
|
1. **Pour les futures familles (Initialisation) :**
|
||||||
|
Mettre à jour le fichier `docker/schema_family.sql`. Ce fichier est lu par `register.php` à la création d'un nouvel espace.
|
||||||
|
2. **Pour les familles existantes (Migration) :**
|
||||||
|
Créer ou mettre à jour le script `migrate.php` à la racine du projet. Ce script boucle sur toutes les bases existantes référencées dans `househub_meta.families` pour y injecter les requêtes `ALTER TABLE` ou `CREATE TABLE`.
|
||||||
|
- **Exécution** : Accéder à `https://votre-domaine.com/migrate.php` avec un navigateur.
|
||||||
|
- ⚠️ **Sécurité** : Pensez à supprimer ou sécuriser l'accès à ce fichier après exécution en production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Roadmap de Variabilisation (Migration Multi-Tenant)
|
||||||
|
|
||||||
|
Afin de transformer complètement l'application héritée de Pacha Family en un produit générique et autonome pour chaque espace familial, les chantiers suivants doivent être menés.
|
||||||
|
|
||||||
|
🟢 Niveau 1 : Facile (Constantes et configurations globales)
|
||||||
|
|
||||||
|
[x] Configuration de base : Extraire la devise (CURRENCY) et la zone de vacances scolaires (ZONE_SCOLAIRE) du fichier config.php pour les stocker dans les paramètres d'espace en BDD.
|
||||||
|
[x] Suppression des IDs statiques : Supprimer les IDs parents fixes (ID_ALEX = 2, ID_LAIA = 3) dans config.php au profit d'une lecture dynamique basée sur les utilisateurs réels de la famille connectée (liens via la table pf_people).
|
||||||
|
[x] Charte graphique dynamique : Remplacer les couleurs codées en dur (bleu/orange) par des sélecteurs natifs dans l'espace "Paramètres", en générant les variables CSS et leurs nuances (via color-mix()) à la volée.
|
||||||
|
[ ] Filtres API Éducation Nationale : Rendre dynamique la zone de l'API (zones LIKE '%Zone C%' dans family-calendar.js) pour s'adapter à la région de chaque foyer.
|
||||||
|
[ ] Identifiant Planka : Variabiliser le project_id Planka par défaut écrit en dur dans planka/api.php.
|
||||||
|
|
||||||
|
🟡 Niveau 2 : Intermédiaire (Listes et dictionnaires en dur)
|
||||||
|
|
||||||
|
[ ] Gestion des membres (Cadeaux) : Dans gift-list.php, remplacer la liste d'enfants ($children) et d'adultes ($baseAdults, $extraAdults) codée en dur par une lecture dynamique des membres du foyer déclarés en BDD.
|
||||||
|
[ ] Occasions festives (Cadeaux) : Rendre configurables les occasions spéciales (TIO, NOEL, ROIS, ANNIV, SANT).
|
||||||
|
[ ] Configuration des catégories (Budget) : Dans budget/views/suivi.php, migrer le tableau $categoriesConfig (FMCG, Essence, École, etc.), leurs couleurs et leurs suggestions de magasins associés vers un stockage paramétrable.
|
||||||
|
[ ] Déductions du Reste à venir (Budget) : Rendre dynamiques les libellés de catégories ciblés pour le calcul automatique du reste à venir (Estimacio escola, Estimation gasolina, etc.).
|
||||||
|
[ ] Types de carburants (Garage) : Variabiliser la liste statique des carburants dans garage.php.
|
||||||
|
|
||||||
|
🔴 Niveau 3 : Complexe (Logique métier et Schéma SQL)
|
||||||
|
|
||||||
|
[x] Refonte relationnelle du Budget (Prévisionnel) : Refonte totale de budget_prev.php. Modification de la table pf_alloc_values pour basculer sur des colonnes génériques (amount_p1, amount_p2) et suppression définitive de toute logique conditionnelle liée aux prénoms des concepteurs originaux.
|
||||||
|
[ ] Refonte globale de la répartition (Budget) : Étendre le modèle relationnel (alloc_id, user_id, amount) au reste de l'application pour gérer n'importe quel nombre d'adultes (1, 2, 3 ou plus).
|
||||||
|
[ ] Destinations de transferts (Budget) : Rendre dynamiques les cibles de virements prévisionnels (vers L.Pol, vers L.Pep, etc.) en fonction des comptes épargne créés par la famille.
|
||||||
|
[ ] Onglets d'Épargne : Générer dynamiquement les sous-onglets de la vue epargne.php à partir des membres réels au lieu des blocs statiques Alex, Laia et Nens.
|
||||||
|
[ ] Types de Garde (Calendrier) : Supprimer les types d'événements et compteurs codés en dur (OFF_CAROLE, EXTRA_OFF_CAROLE, CENTRE, AVIS, PEP_SICK) pour permettre à chaque foyer de définir ses propres modalités de garde et compteurs de congés associés.
|
||||||
|
[ ] Cycles de Congés : Rendre configurables les mois de début de calcul des droits aux congés (Août pour les CP, date d'anniversaire personnalisée pour les JA) par profil utilisateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 Versioning
|
||||||
|
|
||||||
|
| Version | Changements |
|
||||||
|
| ------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| v2.3.0 | Multi-tenant (p.2) : Refonte BDD Budget (p1, p2), suppression IDs statiques et thèmes de profils dynamiques (color-mix()) |
|
||||||
|
| v2.2.0 | Multi-tenant (p.1) : Paramètres dynamiques du foyer (€, Zone) via BDD + script migration |
|
||||||
|
| v2.1.0 | Module Planka : Kanban natif connecté à Planka via API proxy PHP |
|
||||||
|
| v2.0.0 | Module PrintVault : viewer 3D STL/3MF/GCode, stockage local, trajectoires GCode illimitées |
|
||||||
|
| v1.5.0 | Modules Todo (rappels Discord), Mémo, Courses (liste partagée) |
|
||||||
|
| v1.4.0 | Synchronisation CalDAV iOS |
|
||||||
|
| v1.3.0 | Optimisation SQL — suppression des requêtes N+1, temps de réponse ÷3 |
|
||||||
|
| v1.2.0 | Modernisation UI — Toasts, confirmations sur-mesure |
|
||||||
|
| v1.1.0 | Refonte expérience mobile (Bottom Sheets) |
|
||||||
|
| v1.0.0 | Version stable initiale — Budget, Calendrier, Voyages, Cadeaux, Garage |
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../includes/admin_auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/meta_db.php';
|
||||||
|
require_once __DIR__ . '/../includes/i18n.php';
|
||||||
|
|
||||||
|
// ─── Actions POST ──────────────────────────────────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($action === 'toggle_user' && $id) {
|
||||||
|
$meta_pdo->prepare("UPDATE users SET is_active = 1 - is_active WHERE id = ?")->execute([$id]);
|
||||||
|
} elseif ($action === 'toggle_family' && $id) {
|
||||||
|
$meta_pdo->prepare("UPDATE families SET is_active = 1 - is_active WHERE id = ?")->execute([$id]);
|
||||||
|
} elseif ($action === 'toggle_admin' && $id) {
|
||||||
|
// Ne peut pas se retirer ses propres droits admin
|
||||||
|
if ($id !== (int)$_SESSION['user']['id']) {
|
||||||
|
$meta_pdo->prepare("UPDATE users SET is_admin = 1 - is_admin WHERE id = ?")->execute([$id]);
|
||||||
|
}
|
||||||
|
} elseif ($action === 'delete_user' && $id) {
|
||||||
|
if ($id !== (int)$_SESSION['user']['id']) {
|
||||||
|
$meta_pdo->prepare("DELETE FROM users WHERE id = ?")->execute([$id]);
|
||||||
|
}
|
||||||
|
} elseif ($action === 'regen_invite' && $id) {
|
||||||
|
$new_code = bin2hex(random_bytes(8));
|
||||||
|
$meta_pdo->prepare("UPDATE families SET invite_code = ? WHERE id = ?")->execute([$new_code, $id]);
|
||||||
|
}
|
||||||
|
header('Location: /admin/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Données ──────────────────────────────────────────────────────────────────
|
||||||
|
$families = $meta_pdo->query("
|
||||||
|
SELECT f.*, COUNT(u.id) as member_count
|
||||||
|
FROM families f
|
||||||
|
LEFT JOIN users u ON u.family_id = f.id
|
||||||
|
GROUP BY f.id
|
||||||
|
ORDER BY f.id
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
$users = $meta_pdo->query("
|
||||||
|
SELECT u.*, f.name as family_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN families f ON f.id = u.family_id
|
||||||
|
ORDER BY u.id
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
$pageTitle = "Admin — HouseHub";
|
||||||
|
$activePage = "admin";
|
||||||
|
require __DIR__ . '/../header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="pf-container" style="max-width:1000px;margin:32px auto;padding:0 16px">
|
||||||
|
|
||||||
|
<h1 style="font-size:1.5rem;font-weight:700;margin-bottom:4px">⚙️ Panneau d'administration</h1>
|
||||||
|
<p style="color:#64748b;margin-bottom:32px">Gestion des espaces familiaux et des utilisateurs</p>
|
||||||
|
|
||||||
|
<!-- ── Familles ─────────────────────────────────────────────────────────── -->
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:600;margin-bottom:16px">
|
||||||
|
🏠 Espaces familiaux
|
||||||
|
<span style="background:#e2e8f0;border-radius:12px;padding:2px 10px;font-size:0.85rem;font-weight:500;margin-left:8px"><?= count($families) ?></span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div style="overflow-x:auto;margin-bottom:40px">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:0.9rem">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f8fafc;text-align:left">
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">#</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Nom</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">DB</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Code invitation</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Membres</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Statut</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($families as $f): ?>
|
||||||
|
<tr style="border-bottom:1px solid var(--border-light)">
|
||||||
|
<td style="padding:10px 14px;color:#94a3b8"><?= $f['id'] ?></td>
|
||||||
|
<td style="padding:10px 14px;font-weight:600"><?= htmlspecialchars($f['name']) ?></td>
|
||||||
|
<td style="padding:10px 14px"><code style="font-size:0.8rem;background:#f1f5f9;padding:2px 6px;border-radius:4px"><?= $f['db_name'] ?></code></td>
|
||||||
|
<td style="padding:10px 14px">
|
||||||
|
<code style="font-size:0.85rem"><?= $f['invite_code'] ?></code>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 14px;text-align:center"><?= $f['member_count'] ?></td>
|
||||||
|
<td style="padding:10px 14px">
|
||||||
|
<span style="padding:3px 10px;border-radius:12px;font-size:0.8rem;font-weight:600;
|
||||||
|
background:<?= $f['is_active'] ? '#dcfce7' : '#fee2e2' ?>;
|
||||||
|
color:<?= $f['is_active'] ? '#16a34a' : '#dc2626' ?>">
|
||||||
|
<?= $f['is_active'] ? 'Actif' : 'Inactif' ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 14px;display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="action" value="toggle_family">
|
||||||
|
<input type="hidden" name="id" value="<?= $f['id'] ?>">
|
||||||
|
<button class="pf-btn btn-secondary" style="padding:4px 12px;font-size:0.8rem">
|
||||||
|
<?= $f['is_active'] ? 'Désactiver' : 'Activer' ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="action" value="regen_invite">
|
||||||
|
<input type="hidden" name="id" value="<?= $f['id'] ?>">
|
||||||
|
<button class="pf-btn btn-secondary" style="padding:4px 12px;font-size:0.8rem"
|
||||||
|
onclick="return confirm('Regénérer le code ?')">
|
||||||
|
🔄 Code
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Utilisateurs ─────────────────────────────────────────────────────── -->
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:600;margin-bottom:16px">
|
||||||
|
👤 Utilisateurs
|
||||||
|
<span style="background:#e2e8f0;border-radius:12px;padding:2px 10px;font-size:0.85rem;font-weight:500;margin-left:8px"><?= count($users) ?></span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:0.9rem">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f8fafc;text-align:left">
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">#</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Utilisateur</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Espace famille</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Rôle</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Statut</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Créé le</th>
|
||||||
|
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($users as $u): $isSelf = $u['id'] == $_SESSION['user']['id']; ?>
|
||||||
|
<tr style="border-bottom:1px solid var(--border-light)<?= $isSelf ? ';background:#fafff4' : '' ?>">
|
||||||
|
<td style="padding:10px 14px;color:#94a3b8"><?= $u['id'] ?></td>
|
||||||
|
<td style="padding:10px 14px">
|
||||||
|
<strong><?= htmlspecialchars($u['display_name']) ?></strong>
|
||||||
|
<span style="color:#94a3b8;font-size:0.85rem;margin-left:6px">@<?= htmlspecialchars($u['username']) ?></span>
|
||||||
|
<?php if ($isSelf): ?><span style="font-size:0.75rem;color:#2563eb;margin-left:4px">(vous)</span><?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 14px"><?= $u['family_name'] ? htmlspecialchars($u['family_name']) : '<span style="color:#94a3b8">—</span>' ?></td>
|
||||||
|
<td style="padding:10px 14px">
|
||||||
|
<?php if ($u['is_admin']): ?>
|
||||||
|
<span style="background:#dbeafe;color:#1d4ed8;padding:2px 8px;border-radius:10px;font-size:0.8rem;font-weight:600">Admin</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span style="color:#94a3b8;font-size:0.85rem">Membre</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 14px">
|
||||||
|
<span style="padding:3px 10px;border-radius:12px;font-size:0.8rem;font-weight:600;
|
||||||
|
background:<?= $u['is_active'] ? '#dcfce7' : '#fee2e2' ?>;
|
||||||
|
color:<?= $u['is_active'] ? '#16a34a' : '#dc2626' ?>">
|
||||||
|
<?= $u['is_active'] ? 'Actif' : 'Inactif' ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 14px;color:#64748b;font-size:0.85rem"><?= date('d/m/Y', strtotime($u['created_at'])) ?></td>
|
||||||
|
<td style="padding:10px 14px">
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||||
|
<?php if (!$isSelf): ?>
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="action" value="toggle_user">
|
||||||
|
<input type="hidden" name="id" value="<?= $u['id'] ?>">
|
||||||
|
<button class="pf-btn btn-secondary" style="padding:4px 10px;font-size:0.8rem">
|
||||||
|
<?= $u['is_active'] ? 'Désactiver' : 'Activer' ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="action" value="toggle_admin">
|
||||||
|
<input type="hidden" name="id" value="<?= $u['id'] ?>">
|
||||||
|
<button class="pf-btn btn-secondary" style="padding:4px 10px;font-size:0.8rem">
|
||||||
|
<?= $u['is_admin'] ? '↓ Membre' : '↑ Admin' ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer <?= htmlspecialchars($u['username']) ?> ?')">
|
||||||
|
<input type="hidden" name="action" value="delete_user">
|
||||||
|
<input type="hidden" name="id" value="<?= $u['id'] ?>">
|
||||||
|
<button class="pf-btn btn-secondary" style="padding:4px 10px;font-size:0.8rem;color:#dc2626">🗑</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<span style="color:#94a3b8;font-size:0.8rem">—</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../footer.php'; ?>
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
require __DIR__ . '/../includes/db.php';
|
|
||||||
|
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
|
||||||
|
|
||||||
if (!isset($input['action']) || !isset($input['event_id'])) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Action ou ID manquant.']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$action = $input['action'];
|
|
||||||
$eventId = $input['event_id'];
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($action === 'delete') {
|
|
||||||
$stmt = $pdo->prepare("DELETE FROM pf_events WHERE id = ?");
|
|
||||||
$stmt->execute([$eventId]);
|
|
||||||
echo json_encode(['status' => 'success', 'message' => 'Événement supprimé.']);
|
|
||||||
|
|
||||||
} elseif ($action === 'update' && isset($input['new_type'])) {
|
|
||||||
$stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id = ?");
|
|
||||||
$stmt->execute([$input['new_type'], $eventId]);
|
|
||||||
echo json_encode(['status' => 'success', 'message' => 'Événement mis à jour.']);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Action non valide ou données manquantes.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<?php
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
require __DIR__ . '/../includes/db.php';
|
|
||||||
|
|
||||||
$eventsToSave = json_decode(file_get_contents('php://input'), true);
|
|
||||||
|
|
||||||
if (empty($eventsToSave) || !is_array($eventsToSave)) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Aucune donnée d\'événement reçue.']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$inserted = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
|
|
||||||
$sql = "INSERT INTO pf_events (event_date, event_type, person_id, duration)
|
|
||||||
VALUES (:event_date, :event_type, :person_id, :duration)";
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
|
|
||||||
foreach ($eventsToSave as $event) {
|
|
||||||
$person_id = null;
|
|
||||||
|
|
||||||
// Si un nom de personne est fourni
|
|
||||||
if (!empty($event['person'])) {
|
|
||||||
$personStmt = $pdo->prepare("SELECT id FROM pf_people WHERE name = ?");
|
|
||||||
$personStmt->execute([$event['person']]);
|
|
||||||
$personRow = $personStmt->fetch();
|
|
||||||
if ($personRow) {
|
|
||||||
$person_id = $personRow['id'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt->execute([
|
|
||||||
':event_date' => $event['date'],
|
|
||||||
':event_type' => $event['type'],
|
|
||||||
':person_id' => $person_id,
|
|
||||||
':duration' => $event['duration'] ?? 1.0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$inserted[] = [
|
|
||||||
'id' => $pdo->lastInsertId(),
|
|
||||||
'date' => $event['date'],
|
|
||||||
'type' => $event['type'],
|
|
||||||
'duration' => $event['duration'] ?? 1.0,
|
|
||||||
'person_id' => $person_id,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
echo json_encode([
|
|
||||||
'status' => 'success',
|
|
||||||
'inserted' => $inserted,
|
|
||||||
]);
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Erreur lors de la sauvegarde : ' . $e->getMessage()]);
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
/* === RESET SIMPLE === */
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
||||||
sans-serif;
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === LAYOUT GLOBAL === */
|
|
||||||
.pf-container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-header {
|
|
||||||
background: #243b53;
|
|
||||||
color: #fff;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-header-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-logo {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-nav-link {
|
|
||||||
color: #d9e2ec;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-nav-link:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-nav-link--active {
|
|
||||||
background: #102a43;
|
|
||||||
color: #f0f4f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-main {
|
|
||||||
padding: 24px 0 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-section {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-section--panel {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px 16px 20px;
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-footer {
|
|
||||||
border-top: 1px solid #d9e2ec;
|
|
||||||
padding: 8px 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #627d98;
|
|
||||||
background: #f0f4f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === CARDS & FLEX === */
|
|
||||||
|
|
||||||
.pf-card.pf-card--wide {
|
|
||||||
min-width: 400px; /* tu peux monter à 500 ou 600 selon ton goût */
|
|
||||||
flex: 1 1 400px; /* laisse la carte prendre plus de place dans la ligne */
|
|
||||||
}
|
|
||||||
/* Le tableau des vacances occupe toute la largeur disponible */
|
|
||||||
#schoolHolidaysTable {
|
|
||||||
width: auto;
|
|
||||||
table-layout: auto;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-card.pf-card--wide .pf-table-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
/* En-têtes et cellules sans retour à la ligne */
|
|
||||||
#schoolHolidaysTable th,
|
|
||||||
#schoolHolidaysTable td {
|
|
||||||
white-space: nowrap; /* pas de retour à la ligne dans les cellules */
|
|
||||||
overflow: visible; /* pas de tronquage */
|
|
||||||
padding: 4px 8px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-flex {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-flex--wrap {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-gap-lg {
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-card {
|
|
||||||
background: #f8fafc;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #d9e2ec;
|
|
||||||
padding: 12px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-card--small {
|
|
||||||
min-width: 240px;
|
|
||||||
max-width: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-card-title {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-card-body {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === TABLES === */
|
|
||||||
|
|
||||||
/* Appliquer un layout fixe pour harmoniser la largeur des colonnes */
|
|
||||||
#planningTable {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100%; /* le tableau prend toute la largeur disponible */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Colonnes : texte centré et césures possibles */
|
|
||||||
#planningTable th,
|
|
||||||
#planningTable td {
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap; /* évite les retours à la ligne sauvages */
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis; /* coupe avec "..." si le texte déborde */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Colonnes "Mois" et "Semaine" un peu plus étroites */
|
|
||||||
#planningTable th:nth-child(1),
|
|
||||||
#planningTable td:nth-child(1) {
|
|
||||||
width: 2%; /* Mois */
|
|
||||||
}
|
|
||||||
|
|
||||||
#planningTable th:nth-child(2),
|
|
||||||
#planningTable td:nth-child(2) {
|
|
||||||
width: 2%; /* Semaine */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Colonnes jours (Lundi à Vendredi) réparties sur une largeur raisonnable */
|
|
||||||
#planningTable th:nth-child(3),
|
|
||||||
#planningTable td:nth-child(3),
|
|
||||||
#planningTable th:nth-child(4),
|
|
||||||
#planningTable td:nth-child(4),
|
|
||||||
#planningTable th:nth-child(5),
|
|
||||||
#planningTable td:nth-child(5),
|
|
||||||
#planningTable th:nth-child(6),
|
|
||||||
#planningTable td:nth-child(6),
|
|
||||||
#planningTable th:nth-child(7),
|
|
||||||
#planningTable td:nth-child(7) {
|
|
||||||
width: 2%; /* ajustable selon ton rendu */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Colonnes de totaux (Off / Extra / Centre / Avis) un peu plus compactes */
|
|
||||||
#planningTable th:nth-child(8),
|
|
||||||
#planningTable td:nth-child(8),
|
|
||||||
#planningTable th:nth-child(9),
|
|
||||||
#planningTable td:nth-child(9),
|
|
||||||
#planningTable th:nth-child(10),
|
|
||||||
#planningTable td:nth-child(10),
|
|
||||||
#planningTable th:nth-child(11),
|
|
||||||
#planningTable td:nth-child(11) {
|
|
||||||
width: 4%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-table-wrapper {
|
|
||||||
max-height: 600px;
|
|
||||||
overflow: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #d9e2ec;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-table th,
|
|
||||||
.pf-table td {
|
|
||||||
border: 1px solid #d9e2ec;
|
|
||||||
padding: 4px 6px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-table thead th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: #f0f4f8;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-table--compact th,
|
|
||||||
.pf-table--compact td {
|
|
||||||
padding: 3px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === INPUTS === */
|
|
||||||
input[type="number"] {
|
|
||||||
width: 3.1rem;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === COLOR CODING POUR LE FAMILY CALENDAR === */
|
|
||||||
.fc-row--carole-off {
|
|
||||||
background: #ffe0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-row--bank-holiday {
|
|
||||||
background: #d9f7be;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tableau recap vacances scolaires */
|
|
||||||
.fc-holidays-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-holidays-table th,
|
|
||||||
.fc-holidays-table td {
|
|
||||||
border: 1px solid #d9e2ec;
|
|
||||||
padding: 4px 6px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-holidays-table th {
|
|
||||||
background-color: #f0f4f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cellule de jour sélectionnée dans le calendrier */
|
|
||||||
.fc-day--selected {
|
|
||||||
background-color: #bde4ff !important; /* Une couleur de sélection claire, !important pour forcer le style */
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Le menu contextuel flottant */
|
|
||||||
.fc-selection-menu {
|
|
||||||
display: none; /* Caché par défaut */
|
|
||||||
position: absolute; /* Permet de le positionner près de la souris */
|
|
||||||
z-index: 100;
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-selection-menu .fc-menu-section {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.fc-selection-menu .fc-menu-section:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-selection-menu strong {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-selection-menu button {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
background: none;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-selection-menu button:hover {
|
|
||||||
background-color: #f0f4f8;
|
|
||||||
border-color: #b0c4de;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Couleurs de base pour la légende et les jours --- */
|
|
||||||
.fc-day--school-holiday {
|
|
||||||
background-color: #e5d9f2;
|
|
||||||
}
|
|
||||||
.fc-day--public-holiday {
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
}
|
|
||||||
.fc-day--off-carole {
|
|
||||||
background-color: #ffe9a7;
|
|
||||||
}
|
|
||||||
.fc-day--extra-off-carole {
|
|
||||||
background-color: #ffd59b;
|
|
||||||
}
|
|
||||||
.fc-day--has-guard {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bordure verte pour Centre */
|
|
||||||
.fc-day--has-guard.fc-day--centre {
|
|
||||||
border: 2px solid #4caf50;
|
|
||||||
}
|
|
||||||
/* Bordure bleue pour Avis */
|
|
||||||
.fc-day--has-guard.fc-day--avis {
|
|
||||||
border: 2px solid #2196f3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Combinaisons avec dégradés --- */
|
|
||||||
/* Combinaison Vacances + Off Carole */
|
|
||||||
.fc-day--school-holiday.fc-day--off-carole {
|
|
||||||
background-image: linear-gradient(45deg, #e5d9f2 49%, #ffe9a7 51%);
|
|
||||||
}
|
|
||||||
/* Combinaison Vacances + Extra Off Carole */
|
|
||||||
.fc-day--school-holiday.fc-day--extra-off-carole {
|
|
||||||
background-image: linear-gradient(45deg, #e5d9f2 49%, #ffd59b 51%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Eléments pour la légende */
|
|
||||||
.pf-legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
.pf-legend-color {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
margin-right: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
@@ -1,792 +0,0 @@
|
|||||||
/**
|
|
||||||
* family-calendar.js
|
|
||||||
* Distinction congés / modes de garde + modif/suppression locales.
|
|
||||||
*/
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const CONGE_TYPES = ["OFF_CAROLE", "EXTRA_OFF_CAROLE"];
|
|
||||||
const GUARDE_TYPES = ["CENTRE", "AVIS"];
|
|
||||||
const MODIFIABLE_TYPES = [...CONGE_TYPES, ...GUARDE_TYPES];
|
|
||||||
|
|
||||||
class FamilyCalendar {
|
|
||||||
constructor() {
|
|
||||||
this.planningBody = document.getElementById("planningBody");
|
|
||||||
this.selectionMenu = document.getElementById("selectionMenu");
|
|
||||||
this.schoolHolidaysTableBody = document.querySelector(
|
|
||||||
"#schoolHolidaysTable tbody"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!this.planningBody || !this.selectionMenu) {
|
|
||||||
console.error("planningBody ou selectionMenu manquant");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isSelecting = false;
|
|
||||||
this.selectedCells = [];
|
|
||||||
this.dbEvents = [];
|
|
||||||
this.fixedEvents = [];
|
|
||||||
this.events = [];
|
|
||||||
this.menuJustOpened = false;
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
window.cal = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== INIT ==================
|
|
||||||
async init() {
|
|
||||||
this.setupEventListeners();
|
|
||||||
this.weeks = this.generateWeeksStructure();
|
|
||||||
|
|
||||||
this.dbEvents = this.loadDbEvents();
|
|
||||||
this.fixedEvents = await this.fetchPublicAndSchoolHolidays();
|
|
||||||
this.events = [...this.dbEvents, ...this.fixedEvents];
|
|
||||||
|
|
||||||
this.reprocessAndRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadDbEvents() {
|
|
||||||
if (typeof serverData !== "undefined" && Array.isArray(serverData)) {
|
|
||||||
return serverData.map((evt) => ({
|
|
||||||
id: evt.id,
|
|
||||||
date: evt.event_date,
|
|
||||||
type: evt.event_type,
|
|
||||||
duration: parseFloat(evt.duration),
|
|
||||||
person_id: evt.person_id,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchPublicAndSchoolHolidays() {
|
|
||||||
// 1. Jours fériés (fixes)
|
|
||||||
const publicHolidays = [
|
|
||||||
{ date: "2025-11-01", type: "PUBLIC_HOLIDAY", duration: 1 },
|
|
||||||
{ date: "2025-11-11", type: "PUBLIC_HOLIDAY", duration: 1 },
|
|
||||||
{ date: "2025-12-25", type: "PUBLIC_HOLIDAY", duration: 1 },
|
|
||||||
{ date: "2026-01-01", type: "PUBLIC_HOLIDAY", duration: 1 },
|
|
||||||
{ date: "2026-04-06", type: "PUBLIC_HOLIDAY", duration: 1 },
|
|
||||||
{ date: "2026-05-01", type: "PUBLIC_HOLIDAY", duration: 1 },
|
|
||||||
{ date: "2026-05-08", type: "PUBLIC_HOLIDAY", duration: 1 },
|
|
||||||
{ date: "2026-05-14", type: "PUBLIC_HOLIDAY", duration: 1 },
|
|
||||||
{ date: "2026-05-25", type: "PUBLIC_HOLIDAY", duration: 1 },
|
|
||||||
].map((e, idx) => ({
|
|
||||||
id: `ph-${idx}`,
|
|
||||||
...e,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 2. Vacances scolaires (API)
|
|
||||||
const schoolHolidayEvents = [];
|
|
||||||
let holidayRecords = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
"https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=annee_scolaire='2025-2026' AND zones LIKE '%Zone C%'&limit=100"
|
|
||||||
);
|
|
||||||
const schoolHolidaysData = await response.json();
|
|
||||||
holidayRecords = schoolHolidaysData.results || [];
|
|
||||||
|
|
||||||
holidayRecords.forEach((record, index) => {
|
|
||||||
let current = new Date(record.start_date);
|
|
||||||
let end = new Date(record.end_date);
|
|
||||||
|
|
||||||
while (current < end) {
|
|
||||||
const isoDate = `${current.getFullYear()}-${String(
|
|
||||||
current.getMonth() + 1
|
|
||||||
).padStart(2, "0")}-${String(current.getDate()).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
schoolHolidayEvents.push({
|
|
||||||
id: `sh-${isoDate}-${index}`,
|
|
||||||
date: isoDate,
|
|
||||||
type: "VACANCES_SCOLAIRES",
|
|
||||||
duration: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
current.setDate(current.getDate() + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remplir le tableau HTML des vacances
|
|
||||||
this.renderSchoolHolidaysTable(holidayRecords);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Impossible de charger les vacances scolaires.", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Retourne tous les événements "fixes"
|
|
||||||
return [...publicHolidays, ...schoolHolidayEvents];
|
|
||||||
}
|
|
||||||
|
|
||||||
generateWeeksStructure() {
|
|
||||||
const weeks = [];
|
|
||||||
const start = new Date(2025, 8, 1); // 1er septembre 2025
|
|
||||||
const end = new Date(2026, 7, 31); // 31 août 2026
|
|
||||||
let current = new Date(start);
|
|
||||||
|
|
||||||
// Remonter au lundi le plus proche <= start
|
|
||||||
while (current.getDay() !== 1) current.setDate(current.getDate() - 1);
|
|
||||||
|
|
||||||
while (current <= end) {
|
|
||||||
const monday = new Date(current);
|
|
||||||
|
|
||||||
// C’est CETTE date qui doit servir de référence pour le mois
|
|
||||||
const weekMonthDate = monday;
|
|
||||||
|
|
||||||
const weekData = {
|
|
||||||
id: `${monday.getFullYear()}-W${getWeekOfYear(monday)}`,
|
|
||||||
monthKey: `${weekMonthDate.getFullYear()}-${String(
|
|
||||||
weekMonthDate.getMonth() + 1
|
|
||||||
).padStart(2, "0")}`,
|
|
||||||
monthName: getMonthNameFr(weekMonthDate.getMonth()),
|
|
||||||
weekLabel: `W${getWeekOfYear(monday)}`,
|
|
||||||
dayDates: {},
|
|
||||||
dayFlags: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
["mon", "tue", "wed", "thu", "fri"].forEach((dayKey, index) => {
|
|
||||||
const dayDate = new Date(monday);
|
|
||||||
dayDate.setDate(monday.getDate() + index);
|
|
||||||
weekData.dayDates[dayKey] = dayDate;
|
|
||||||
weekData.dayFlags[dayKey] = { eventsOnDay: [] };
|
|
||||||
});
|
|
||||||
|
|
||||||
weeks.push(weekData);
|
|
||||||
current.setDate(current.getDate() + 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Juste pour verrouiller l’ordre si un jour tu modifies current ailleurs
|
|
||||||
weeks.sort((a, b) => a.dayDates.mon - b.dayDates.mon);
|
|
||||||
|
|
||||||
return weeks;
|
|
||||||
}
|
|
||||||
|
|
||||||
reprocessAndRender() {
|
|
||||||
this.reprocessEvents();
|
|
||||||
this.renderTable();
|
|
||||||
this.updateGlobalSummary();
|
|
||||||
}
|
|
||||||
|
|
||||||
reprocessEvents() {
|
|
||||||
this.weeks.forEach((w) => {
|
|
||||||
w.totals = { offCarole: 0, extraOffCarole: 0, centre: 0, avis: 0 };
|
|
||||||
Object.values(w.dayFlags).forEach((df) => (df.eventsOnDay = []));
|
|
||||||
});
|
|
||||||
|
|
||||||
this.events.forEach((evt) => {
|
|
||||||
const evtDate = new Date(evt.date + "T00:00:00");
|
|
||||||
const week = this.weeks.find(
|
|
||||||
(w) => evtDate >= w.dayDates.mon && evtDate <= w.dayDates.fri
|
|
||||||
);
|
|
||||||
if (!week) return;
|
|
||||||
|
|
||||||
const dayKey = Object.keys(week.dayDates).find(
|
|
||||||
(k) => week.dayDates[k].toDateString() === evtDate.toDateString()
|
|
||||||
);
|
|
||||||
if (dayKey) {
|
|
||||||
week.dayFlags[dayKey].eventsOnDay.push(evt);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dur = parseFloat(evt.duration) || 1;
|
|
||||||
switch (evt.type) {
|
|
||||||
case "OFF_CAROLE":
|
|
||||||
week.totals.offCarole += dur;
|
|
||||||
break;
|
|
||||||
case "EXTRA_OFF_CAROLE":
|
|
||||||
week.totals.extraOffCarole += dur;
|
|
||||||
break;
|
|
||||||
case "CENTRE":
|
|
||||||
week.totals.centre += dur;
|
|
||||||
break;
|
|
||||||
case "AVIS":
|
|
||||||
week.totals.avis += dur;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateGlobalSummary() {
|
|
||||||
const summaryDiv = document.getElementById("globalSummary");
|
|
||||||
if (!summaryDiv) return;
|
|
||||||
|
|
||||||
// On filtre uniquement sur les événements "DB" pour le récap (pas les vacances/fériés)
|
|
||||||
const allEvents = this.dbEvents || [];
|
|
||||||
|
|
||||||
const totalOff = allEvents
|
|
||||||
.filter((e) => e.type === "OFF_CAROLE")
|
|
||||||
.reduce((sum, e) => sum + Number(e.duration || 1), 0);
|
|
||||||
|
|
||||||
const totalExtraOff = allEvents
|
|
||||||
.filter((e) => e.type === "EXTRA_OFF_CAROLE")
|
|
||||||
.reduce((sum, e) => sum + Number(e.duration || 1), 0);
|
|
||||||
|
|
||||||
summaryDiv.innerHTML = `
|
|
||||||
<p><strong>Off Carole :</strong> ${totalOff} jours</p>
|
|
||||||
<p><strong>Extra Off Carole :</strong> ${totalExtraOff} jours</p>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSchoolHolidaysTable(holidayRecords) {
|
|
||||||
if (!this.schoolHolidaysTableBody) return;
|
|
||||||
this.schoolHolidaysTableBody.innerHTML = "";
|
|
||||||
|
|
||||||
const uniqueHolidays = new Map();
|
|
||||||
holidayRecords.forEach((r) =>
|
|
||||||
uniqueHolidays.set(`${r.start_date}|${r.end_date}`, r)
|
|
||||||
);
|
|
||||||
|
|
||||||
[...uniqueHolidays.values()]
|
|
||||||
.sort((a, b) => new Date(a.start_date) - new Date(b.start_date))
|
|
||||||
.forEach((record) => {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const startDate = new Date(record.start_date);
|
|
||||||
const endDate = new Date(record.end_date);
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td>${record.description}</td>
|
|
||||||
<td>${startDate.toLocaleDateString("fr-FR")}</td>
|
|
||||||
<td>${endDate.toLocaleDateString("fr-FR")}</td>
|
|
||||||
<td>${record.zones}</td>
|
|
||||||
`;
|
|
||||||
this.schoolHolidaysTableBody.appendChild(tr);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTable() {
|
|
||||||
this.planningBody.innerHTML = "";
|
|
||||||
const monthSpans = this.weeks.reduce(
|
|
||||||
(acc, w) => ({ ...acc, [w.monthKey]: (acc[w.monthKey] || 0) + 1 }),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
const monthRowRendered = {};
|
|
||||||
const formatTotal = (total) =>
|
|
||||||
total > 0 ? (Number.isInteger(total) ? total : total.toFixed(1)) : "";
|
|
||||||
|
|
||||||
this.weeks.forEach((week) => {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
|
|
||||||
if (!monthRowRendered[week.monthKey]) {
|
|
||||||
monthRowRendered[week.monthKey] = true;
|
|
||||||
const tdMonth = document.createElement("td");
|
|
||||||
tdMonth.rowSpan = monthSpans[week.monthKey] || 1;
|
|
||||||
tdMonth.textContent = week.monthName;
|
|
||||||
tr.appendChild(tdMonth);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tdWeek = document.createElement("td");
|
|
||||||
tdWeek.textContent = week.weekLabel;
|
|
||||||
tr.appendChild(tdWeek);
|
|
||||||
|
|
||||||
["mon", "tue", "wed", "thu", "fri"].forEach((dayKey) => {
|
|
||||||
const td = document.createElement("td");
|
|
||||||
const dayDate = week.dayDates[dayKey];
|
|
||||||
td.dataset.date = `${dayDate.getFullYear()}-${String(
|
|
||||||
dayDate.getMonth() + 1
|
|
||||||
).padStart(2, "0")}-${String(dayDate.getDate()).padStart(2, "0")}`;
|
|
||||||
td.textContent = `${String(dayDate.getDate()).padStart(
|
|
||||||
2,
|
|
||||||
"0"
|
|
||||||
)}/${String(dayDate.getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
td.className = "";
|
|
||||||
let hasGarde = false;
|
|
||||||
let gardeType = null;
|
|
||||||
|
|
||||||
week.dayFlags[dayKey].eventsOnDay.forEach((evt) => {
|
|
||||||
const classMap = {
|
|
||||||
VACANCES_SCOLAIRES: "fc-day--school-holiday",
|
|
||||||
PUBLIC_HOLIDAY: "fc-day--public-holiday",
|
|
||||||
OFF_CAROLE: "fc-day--off-carole",
|
|
||||||
EXTRA_OFF_CAROLE: "fc-day--extra-off-carole",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (classMap[evt.type]) {
|
|
||||||
td.classList.add(classMap[evt.type]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (GUARDE_TYPES.includes(evt.type)) {
|
|
||||||
hasGarde = true;
|
|
||||||
gardeType = evt.type;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasGarde) {
|
|
||||||
td.classList.add("fc-day--has-guard");
|
|
||||||
if (gardeType === "CENTRE") td.classList.add("fc-day--centre");
|
|
||||||
if (gardeType === "AVIS") td.classList.add("fc-day--avis");
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.appendChild(td);
|
|
||||||
});
|
|
||||||
|
|
||||||
const tdOff = document.createElement("td");
|
|
||||||
tdOff.textContent = formatTotal(week.totals.offCarole);
|
|
||||||
tr.appendChild(tdOff);
|
|
||||||
|
|
||||||
const tdExtra = document.createElement("td");
|
|
||||||
tdExtra.textContent = formatTotal(week.totals.extraOffCarole);
|
|
||||||
tr.appendChild(tdExtra);
|
|
||||||
|
|
||||||
const tdCentre = document.createElement("td");
|
|
||||||
tdCentre.textContent = formatTotal(week.totals.centre);
|
|
||||||
tr.appendChild(tdCentre);
|
|
||||||
|
|
||||||
const tdAvis = document.createElement("td");
|
|
||||||
tdAvis.textContent = formatTotal(week.totals.avis);
|
|
||||||
tr.appendChild(tdAvis);
|
|
||||||
|
|
||||||
this.planningBody.appendChild(tr);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== INTERACTIONS ==================
|
|
||||||
setupEventListeners() {
|
|
||||||
this.planningBody.addEventListener("mousedown", (e) =>
|
|
||||||
this.handleMouseDown(e)
|
|
||||||
);
|
|
||||||
document.addEventListener("mousemove", (e) => this.handleMouseMove(e));
|
|
||||||
document.addEventListener("mouseup", (e) => this.handleMouseUp(e));
|
|
||||||
document.addEventListener(
|
|
||||||
"click",
|
|
||||||
(e) => this.handleClickOutsideMenu(e),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
this.selectionMenu.addEventListener("click", (e) =>
|
|
||||||
this.handleMenuClick(e)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseDown(e) {
|
|
||||||
const cell = e.target.closest("td[data-date]");
|
|
||||||
if (!cell) return;
|
|
||||||
e.preventDefault();
|
|
||||||
this.clearSelection();
|
|
||||||
|
|
||||||
this.isSelecting = true;
|
|
||||||
cell.classList.add("fc-day--selected");
|
|
||||||
this.selectedCells.push(cell);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseMove(e) {
|
|
||||||
if (!this.isSelecting) return;
|
|
||||||
const cell = e.target.closest("td[data-date]");
|
|
||||||
if (cell && !this.selectedCells.includes(cell)) {
|
|
||||||
cell.classList.add("fc-day--selected");
|
|
||||||
this.selectedCells.push(cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseUp(e) {
|
|
||||||
if (!this.isSelecting) return;
|
|
||||||
this.isSelecting = false;
|
|
||||||
if (this.selectedCells.length === 0) return;
|
|
||||||
|
|
||||||
if (this.selectedCells.length === 1) {
|
|
||||||
const date = this.selectedCells[0].dataset.date;
|
|
||||||
const eventsOnDay = this.events.filter(
|
|
||||||
(evt) => evt.date === date && MODIFIABLE_TYPES.includes(evt.type)
|
|
||||||
);
|
|
||||||
const conge = eventsOnDay.find((e) => CONGE_TYPES.includes(e.type));
|
|
||||||
const garde = eventsOnDay.find((e) => GUARDE_TYPES.includes(e.type));
|
|
||||||
|
|
||||||
if (!conge && !garde) {
|
|
||||||
this.showAddMenu(e);
|
|
||||||
} else {
|
|
||||||
this.showEditMenuForDay(e, { conge, garde, date });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Multi-jours : on reste en mode ajout, avec contrôles plus tard
|
|
||||||
this.showAddMenu(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClickOutsideMenu(e) {
|
|
||||||
if (this.menuJustOpened) {
|
|
||||||
this.menuJustOpened = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
this.selectionMenu.style.display === "block" &&
|
|
||||||
!this.selectionMenu.contains(e.target)
|
|
||||||
) {
|
|
||||||
this.clearSelection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSelection() {
|
|
||||||
this.selectionMenu.style.display = "none";
|
|
||||||
this.selectedCells.forEach((cell) =>
|
|
||||||
cell.classList.remove("fc-day--selected")
|
|
||||||
);
|
|
||||||
this.selectedCells = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
showAddMenu(e) {
|
|
||||||
this.selectionMenu.innerHTML = `
|
|
||||||
<div class="fc-menu-section"><strong>Ajouter</strong></div>
|
|
||||||
<button data-action="add" data-type="OFF_CAROLE" data-person="Carole">Off Carole</button>
|
|
||||||
<button data-action="add" data-type="EXTRA_OFF_CAROLE" data-person="Carole">Extra Off Carole</button>
|
|
||||||
<div class="fc-menu-section"><strong>Mode de Garde</strong></div>
|
|
||||||
<button data-action="add" data-type="CENTRE">Centre</button>
|
|
||||||
<button data-action="add" data-type="AVIS">Avis</button>
|
|
||||||
`;
|
|
||||||
this.positionAndShowMenu(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
showEditMenuForDay(e, { conge, garde, date }) {
|
|
||||||
console.log(
|
|
||||||
"[EDIT MENU] pour date",
|
|
||||||
date,
|
|
||||||
"conge =",
|
|
||||||
conge,
|
|
||||||
"garde =",
|
|
||||||
garde
|
|
||||||
);
|
|
||||||
let congeSection = "";
|
|
||||||
if (conge) {
|
|
||||||
const oppositeConge =
|
|
||||||
conge.type === "OFF_CAROLE" ? "EXTRA_OFF_CAROLE" : "OFF_CAROLE";
|
|
||||||
congeSection = `
|
|
||||||
<div class="fc-menu-section"><strong>Congé Carole</strong></div>
|
|
||||||
<button data-action="update" data-event-id="${
|
|
||||||
conge.id
|
|
||||||
}" data-new-type="${oppositeConge}">
|
|
||||||
Remplacer par ${oppositeConge.replace(/_/g, " ")}
|
|
||||||
</button>
|
|
||||||
<button data-action="delete" data-event-id="${conge.id}">
|
|
||||||
Supprimer le congé
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
congeSection = `
|
|
||||||
<div class="fc-menu-section"><strong>Ajouter un congé</strong></div>
|
|
||||||
<button data-action="add-single" data-type="OFF_CAROLE" data-date="${date}" data-person="Carole">
|
|
||||||
Off Carole
|
|
||||||
</button>
|
|
||||||
<button data-action="add-single" data-type="EXTRA_OFF_CAROLE" data-date="${date}" data-person="Carole">
|
|
||||||
Extra Off Carole
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let gardeSection = "";
|
|
||||||
if (garde) {
|
|
||||||
const oppositeGarde = garde.type === "CENTRE" ? "AVIS" : "CENTRE";
|
|
||||||
gardeSection = `
|
|
||||||
<div class="fc-menu-section"><strong>Mode de garde</strong></div>
|
|
||||||
<button data-action="update" data-event-id="${garde.id}" data-new-type="${oppositeGarde}">
|
|
||||||
Remplacer par ${oppositeGarde}
|
|
||||||
</button>
|
|
||||||
<button data-action="delete" data-event-id="${garde.id}">
|
|
||||||
Supprimer le mode de garde
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
gardeSection = `
|
|
||||||
<div class="fc-menu-section"><strong>Ajouter mode de garde</strong></div>
|
|
||||||
<button data-action="add-single" data-type="CENTRE" data-date="${date}">
|
|
||||||
Centre
|
|
||||||
</button>
|
|
||||||
<button data-action="add-single" data-type="AVIS" data-date="${date}">
|
|
||||||
Avis
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.selectionMenu.innerHTML = congeSection + gardeSection;
|
|
||||||
this.positionAndShowMenu(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
positionAndShowMenu(e) {
|
|
||||||
this.selectionMenu.style.display = "block";
|
|
||||||
this.selectionMenu.style.left = `${e.pageX + 5}px`;
|
|
||||||
this.selectionMenu.style.top = `${e.pageY + 5}px`;
|
|
||||||
this.menuJustOpened = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleMenuClick(e) {
|
|
||||||
const button = e.target.closest("button[data-action]");
|
|
||||||
if (!button) return;
|
|
||||||
|
|
||||||
const { action, eventId, newType, type, person, date } = button.dataset;
|
|
||||||
|
|
||||||
// === AJOUT MULTI-JOURS (sélection de plusieurs cellules) ===
|
|
||||||
if (action === "add") {
|
|
||||||
const selectedDates = this.selectedCells.map((c) => c.dataset.date);
|
|
||||||
const existingOnDates = this.dbEvents.filter((evt) =>
|
|
||||||
selectedDates.includes(evt.date)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Contrôles métier : pas de double congé / pas de double mode de garde
|
|
||||||
if (CONGE_TYPES.includes(type)) {
|
|
||||||
const hasCongeConflict = existingOnDates.some((evt) =>
|
|
||||||
CONGE_TYPES.includes(evt.type)
|
|
||||||
);
|
|
||||||
if (hasCongeConflict) {
|
|
||||||
alert(
|
|
||||||
"Impossible d'ajouter un congé : au moins une date a déjà un congé (Off/Extra Off)."
|
|
||||||
);
|
|
||||||
this.clearSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (GUARDE_TYPES.includes(type)) {
|
|
||||||
const hasGardeConflict = existingOnDates.some((evt) =>
|
|
||||||
GUARDE_TYPES.includes(evt.type)
|
|
||||||
);
|
|
||||||
if (hasGardeConflict) {
|
|
||||||
alert(
|
|
||||||
"Impossible d'ajouter un mode de garde : au moins une date a déjà Centre/Avis."
|
|
||||||
);
|
|
||||||
this.clearSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEvents = this.selectedCells.map((cell) => ({
|
|
||||||
date: cell.dataset.date,
|
|
||||||
type,
|
|
||||||
person,
|
|
||||||
duration: 1.0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.clearSelection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/save-events.php", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(newEvents),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Erreur HTTP " + response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resync DB events
|
|
||||||
const resEvents = await fetch("/api/get-events.php");
|
|
||||||
if (!resEvents.ok) {
|
|
||||||
throw new Error("Erreur lors du rechargement des événements.");
|
|
||||||
}
|
|
||||||
const dataEvents = await resEvents.json();
|
|
||||||
|
|
||||||
this.dbEvents = dataEvents.events || [];
|
|
||||||
this.events = [...this.dbEvents, ...this.fixedEvents];
|
|
||||||
|
|
||||||
this.reprocessAndRender();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert("Erreur lors de l'ajout.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === AJOUT SUR UNE SEULE DATE (menu combiné d'un jour) ===
|
|
||||||
if (action === "add-single") {
|
|
||||||
const targetDate = date;
|
|
||||||
const existingOnDate = this.dbEvents.filter(
|
|
||||||
(evt) => evt.date === targetDate
|
|
||||||
);
|
|
||||||
|
|
||||||
if (CONGE_TYPES.includes(type)) {
|
|
||||||
const hasCongeConflict = existingOnDate.some((evt) =>
|
|
||||||
CONGE_TYPES.includes(evt.type)
|
|
||||||
);
|
|
||||||
if (hasCongeConflict) {
|
|
||||||
alert("Un congé existe déjà ce jour-là.");
|
|
||||||
this.clearSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (GUARDE_TYPES.includes(type)) {
|
|
||||||
const hasGardeConflict = existingOnDate.some((evt) =>
|
|
||||||
GUARDE_TYPES.includes(evt.type)
|
|
||||||
);
|
|
||||||
if (hasGardeConflict) {
|
|
||||||
alert("Un mode de garde existe déjà ce jour-là.");
|
|
||||||
this.clearSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEvent = {
|
|
||||||
date: targetDate,
|
|
||||||
type,
|
|
||||||
person,
|
|
||||||
duration: 1.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.clearSelection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/save-events.php", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify([newEvent]),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Erreur HTTP " + response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resync DB events
|
|
||||||
const resEvents = await fetch("/api/get-events.php");
|
|
||||||
if (!resEvents.ok) {
|
|
||||||
throw new Error("Erreur lors du rechargement des événements.");
|
|
||||||
}
|
|
||||||
const dataEvents = await resEvents.json();
|
|
||||||
|
|
||||||
this.dbEvents = dataEvents.events || [];
|
|
||||||
this.events = [...this.dbEvents, ...this.fixedEvents];
|
|
||||||
|
|
||||||
this.reprocessAndRender();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert("Erreur lors de l'ajout.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === SUPPRESSION ===
|
|
||||||
if (action === "delete") {
|
|
||||||
if (!eventId) {
|
|
||||||
console.warn("Bouton delete sans eventId, action ignorée.");
|
|
||||||
this.clearSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clearSelection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/manage-event.php", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ action: "delete", event_id: eventId }),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(
|
|
||||||
errData.message || "Erreur HTTP " + response.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resync DB events
|
|
||||||
const resEvents = await fetch("/api/get-events.php");
|
|
||||||
if (!resEvents.ok) {
|
|
||||||
throw new Error("Erreur lors du rechargement des événements.");
|
|
||||||
}
|
|
||||||
const dataEvents = await resEvents.json();
|
|
||||||
|
|
||||||
this.dbEvents = dataEvents.events || [];
|
|
||||||
this.events = [...this.dbEvents, ...this.fixedEvents];
|
|
||||||
|
|
||||||
this.reprocessAndRender();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert("Erreur lors de la suppression : " + err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === MODIFICATION (change OFF <-> EXTRA ou CENTRE <-> AVIS) ===
|
|
||||||
if (action === "update") {
|
|
||||||
console.log("[UPDATE] click sur bouton update", { eventId, newType });
|
|
||||||
|
|
||||||
if (!eventId) {
|
|
||||||
console.warn("Bouton update sans eventId, action ignorée.");
|
|
||||||
this.clearSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clearSelection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/manage-event.php", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action: "update",
|
|
||||||
event_id: eventId,
|
|
||||||
new_type: newType,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(
|
|
||||||
errData.message || "Erreur HTTP " + response.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resync DB events
|
|
||||||
const resEvents = await fetch("/api/get-events.php");
|
|
||||||
if (!resEvents.ok) {
|
|
||||||
throw new Error("Erreur lors du rechargement des événements.");
|
|
||||||
}
|
|
||||||
const dataEvents = await resEvents.json();
|
|
||||||
|
|
||||||
this.dbEvents = dataEvents.events || [];
|
|
||||||
this.events = [...this.dbEvents, ...this.fixedEvents];
|
|
||||||
|
|
||||||
this.reprocessAndRender();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[UPDATE] Erreur lors de la modification :", err);
|
|
||||||
alert("Erreur lors de la modification : " + err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async manageEvent(payload) {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/manage-event.php", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errData.message || "Erreur HTTP " + response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resEvents = await fetch("/api/get-events.php");
|
|
||||||
if (!resEvents.ok) {
|
|
||||||
throw new Error("Erreur lors du rechargement des événements.");
|
|
||||||
}
|
|
||||||
const dataEvents = await resEvents.json();
|
|
||||||
|
|
||||||
this.dbEvents = dataEvents.events || [];
|
|
||||||
this.events = [...this.dbEvents, ...this.fixedEvents];
|
|
||||||
|
|
||||||
this.reprocessAndRender();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Erreur manageEvent :", error);
|
|
||||||
alert("Erreur: " + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWeekOfYear(date) {
|
|
||||||
const d = new Date(
|
|
||||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
|
|
||||||
);
|
|
||||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
|
||||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
||||||
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMonthNameFr(monthIndex) {
|
|
||||||
return (
|
|
||||||
[
|
|
||||||
"Janvier",
|
|
||||||
"Fevrier",
|
|
||||||
"Mars",
|
|
||||||
"Avril",
|
|
||||||
"Mai",
|
|
||||||
"Juin",
|
|
||||||
"Juillet",
|
|
||||||
"Aout",
|
|
||||||
"Septembre",
|
|
||||||
"Octobre",
|
|
||||||
"Novembre",
|
|
||||||
"Decembre",
|
|
||||||
][monthIndex] || ""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
new FamilyCalendar();
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
require_login('/login.php');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DIAGNOSTIC ONBOARDING (Vérifie si le budget est prêt à être utilisé)
|
||||||
|
// ============================================================================
|
||||||
|
$currentYear = date('Y');
|
||||||
|
$hasCategories = ((int)$pdo->query("SELECT COUNT(*) FROM pf_budget_categories")->fetchColumn() > 0);
|
||||||
|
$hasSalaries = ((int)$pdo->query("SELECT COUNT(*) FROM pf_salary_config WHERE year = $currentYear")->fetchColumn() > 0);
|
||||||
|
$hasAccounts = ((int)$pdo->query("SELECT COUNT(*) FROM pf_bank_accounts")->fetchColumn() > 0);
|
||||||
|
$hasBudgetItems = ((int)$pdo->query("SELECT COUNT(*) FROM pf_budget_items")->fetchColumn() > 0);
|
||||||
|
|
||||||
|
// Le minimum vital pour que ça tourne
|
||||||
|
$isBudgetSetupOk = ($hasCategories && $hasSalaries && $hasAccounts);
|
||||||
|
|
||||||
|
// Gestion de l'onglet actif (par défaut 'suivi')
|
||||||
|
$tab = $_GET['tab'] ?? 'suivi';
|
||||||
|
|
||||||
|
$pageTitle = tr('budget_page_title');
|
||||||
|
$activePage = "budget";
|
||||||
|
$pageCss = "/modules/budget/budget.css";
|
||||||
|
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="pf-container">
|
||||||
|
<div class="pf-hero budget-hero">
|
||||||
|
<div class="budget-title-group">
|
||||||
|
<h1><?= tr('budget_main_header') ?></h1>
|
||||||
|
<!-- Bouton avec séparation claire entre l'engrenage animable et l'alerte fixe -->
|
||||||
|
<button class="btn-header-settings" onclick="openBudgetSettings()" title="<?= tr('settings') ?>">
|
||||||
|
<span class="gear-icon-anim">⚙️</span>
|
||||||
|
<?php if (!$isBudgetSetupOk || !$hasBudgetItems): ?>
|
||||||
|
<span class="alert-dot">!</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="budget-tabs-container">
|
||||||
|
<a href="?tab=suivi" class="tab-item <?= $tab == 'suivi' ? 'active' : '' ?>">
|
||||||
|
<span class="tab-icon">🗓️</span>
|
||||||
|
<span><?= tr('budget_tab_tracking') ?></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="?tab=budget_prev" class="tab-item <?= $tab == 'budget_prev' ? 'active' : '' ?>">
|
||||||
|
<span class="tab-icon">🎯</span>
|
||||||
|
<span><?= tr('budget_tab_prev') ?></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="?tab=epargne" class="tab-item <?= $tab == 'epargne' ? 'active' : '' ?>">
|
||||||
|
<span class="tab-icon">🐷</span>
|
||||||
|
<span><?= tr('budget_tab_savings') ?></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="?tab=recap" class="tab-item <?= $tab == 'recap' ? 'active' : '' ?>">
|
||||||
|
<span class="tab-icon">📊</span>
|
||||||
|
<span><?= tr('budget_tab_recap') ?></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="?tab=provisions" class="tab-item <?= $tab == 'provisions' ? 'active' : '' ?>">
|
||||||
|
<span class="tab-icon">🧮</span>
|
||||||
|
<span><?= tr('budget_tab_provisions') ?></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="pf-section">
|
||||||
|
<?php
|
||||||
|
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev', 'provisions'];
|
||||||
|
|
||||||
|
if (in_array($tab, $allowedTabs)) {
|
||||||
|
$viewPath = __DIR__ . "/modules/budget/views/" . $tab . ".php";
|
||||||
|
|
||||||
|
if (file_exists($viewPath)) {
|
||||||
|
include $viewPath;
|
||||||
|
} else {
|
||||||
|
echo "<div class='budget-error-msg'>";
|
||||||
|
echo "<h3>" . tr('budget_err_file_not_found') . "</h3>";
|
||||||
|
echo "<p>" . sprintf(tr('budget_err_file_detail'), htmlspecialchars($tab)) . "</p>";
|
||||||
|
echo "</div>";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "<p>" . tr('budget_err_not_authorized') . "</p>";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
require __DIR__ . '/modules/budget/views/settings_modal.php';
|
||||||
|
require __DIR__ . '/footer.php';
|
||||||
|
?>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = 'Calendrier iOS — HouseHub';
|
||||||
|
$activePage = 'calendar-ios';
|
||||||
|
$mainClass = 'pf-calendar-ios-page';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/calendar-ios/assets/calendar-ios.css">
|
||||||
|
|
||||||
|
<div class="pf-container ios-calendar-wrap">
|
||||||
|
<section class="pf-panel-card">
|
||||||
|
<div class="ios-calendar-head">
|
||||||
|
<h1>📱 <?= tr('menu_calendar_ios') ?></h1>
|
||||||
|
<div class="ios-calendar-head-actions">
|
||||||
|
<button type="button" class="ios-agenda-btn ios-agenda-btn--ghost" id="ios-sync-btn">Synchroniser</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="ios-agenda-intro">Les événements viennent de HouseHub après synchro iCloud. <strong>Synchroniser</strong> met à jour tous les calendriers du compte ; les changements locaux partent au prochain envoi.</p>
|
||||||
|
|
||||||
|
<details class="ios-cal-prefs-details" id="ios-cal-prefs-details">
|
||||||
|
<summary class="ios-cal-prefs-summary">Calendriers affichés et couleurs</summary>
|
||||||
|
<p class="ios-cal-prefs-help">Décoche un calendrier pour le masquer dans l’agenda. La couleur est libre (repère la teinte iOS sur ton téléphone puis copie-la avec le sélecteur).</p>
|
||||||
|
<div id="ios-calendar-prefs-rows" class="ios-cal-prefs-rows"></div>
|
||||||
|
<p id="ios-calendar-prefs-msg" class="ios-cal-prefs-msg" role="status"></p>
|
||||||
|
<button type="button" class="pf-btn" id="ios-calendar-prefs-save">Enregistrer affichage & couleurs</button>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pf-panel-card ios-agenda-card">
|
||||||
|
<div class="ios-agenda-toolbar">
|
||||||
|
<div class="ios-agenda-title" id="ios-agenda-title" aria-live="polite">—</div>
|
||||||
|
<div class="ios-agenda-segments" role="tablist">
|
||||||
|
<button type="button" class="ios-seg" data-ios-view="day" role="tab">Jour</button>
|
||||||
|
<button type="button" class="ios-seg ios-seg--active" data-ios-view="week" role="tab">Semaine</button>
|
||||||
|
<button type="button" class="ios-seg" data-ios-view="month" role="tab">Mois</button>
|
||||||
|
</div>
|
||||||
|
<div class="ios-agenda-nav">
|
||||||
|
<button type="button" class="ios-agenda-btn" id="ios-agenda-prev" aria-label="Période précédente">‹</button>
|
||||||
|
<button type="button" class="ios-agenda-btn" id="ios-agenda-next" aria-label="Période suivante">›</button>
|
||||||
|
<button type="button" class="ios-agenda-today" id="ios-agenda-today">Aujourd'hui</button>
|
||||||
|
<button type="button" class="ios-agenda-add" id="ios-agenda-add" title="Nouvel événement">+</button>
|
||||||
|
<button type="button" class="ios-agenda-listlink" id="ios-view-list">Liste</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="ios-agenda-mount" class="ios-agenda-mount"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pf-panel-card" id="ios-edit-panel">
|
||||||
|
<h2 class="pf-card-h2">Ajouter / Modifier un événement</h2>
|
||||||
|
<form id="ios-event-form" class="ios-form-grid">
|
||||||
|
<input type="hidden" id="ios-event-id">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Titre</label>
|
||||||
|
<input class="pf-input" id="ios-title" required>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Lieu</label>
|
||||||
|
<input class="pf-input" id="ios-location">
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Début</label>
|
||||||
|
<input class="pf-input" id="ios-start" type="datetime-local" required>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Fin</label>
|
||||||
|
<input class="pf-input" id="ios-end" type="datetime-local" required>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group ios-form-full">
|
||||||
|
<label class="pf-label">Description</label>
|
||||||
|
<textarea class="pf-input" id="ios-description"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="ios-form-full ios-form-actions">
|
||||||
|
<button class="pf-btn" type="submit">Enregistrer</button>
|
||||||
|
<button class="pf-btn btn-secondary" type="button" id="ios-form-reset">Réinitialiser</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pf-panel-card" id="ios-list-section" style="display:none">
|
||||||
|
<h2 class="pf-card-h2">Événements (liste)</h2>
|
||||||
|
<div id="ios-sync-status" class="ios-agenda-intro"></div>
|
||||||
|
<div id="ios-events-list" class="ios-events-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/modules/calendar-ios/assets/calendar-ios.js"></script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
require_once dirname(__DIR__) . '/includes/meta_db.php';
|
||||||
|
|
||||||
|
$rows = $meta_pdo->query("SELECT user_id FROM user_calendar_integrations WHERE provider='icloud_caldav' AND status='connected'")->fetchAll();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$userId = (int)$row['user_id'];
|
||||||
|
$url = 'http://localhost/modules/calendar-ios/api.php?action=sync';
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 20,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'X-Requested-With: XMLHttpRequest',
|
||||||
|
// Le cron réel devra utiliser un mécanisme d'auth/session technique.
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Cron: Todo daily reminder notifications via Discord webhook
|
||||||
|
* Runs every minute — sends Discord reminder when due_time is reached.
|
||||||
|
* Repeats every day at the same time (resets at midnight via notified_date).
|
||||||
|
* Usage: php /opt/container/househub/cron/todo_notify.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
date_default_timezone_set('Europe/Paris');
|
||||||
|
|
||||||
|
$DB_HOST = getenv('DB_HOST') ?: 'househub-db';
|
||||||
|
$DB_USER = getenv('DB_USER') ?: 'househub';
|
||||||
|
$DB_PASS = getenv('DB_PASS') ?: 'changeme';
|
||||||
|
$DB_ROOT = getenv('DB_ROOT_PASS') ?: 'rootchangeme';
|
||||||
|
|
||||||
|
// Load .env if running from CLI outside container
|
||||||
|
$env_file = dirname(__DIR__) . '/.env';
|
||||||
|
if (file_exists($env_file)) {
|
||||||
|
foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
|
if (strpos($line, '=') !== false && $line[0] !== '#') {
|
||||||
|
[$k, $v] = explode('=', $line, 2);
|
||||||
|
$k = trim($k); $v = trim($v);
|
||||||
|
if (!getenv($k)) putenv("$k=$v");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$DB_HOST = getenv('DB_HOST') ?: $DB_HOST;
|
||||||
|
$DB_USER = getenv('DB_USER') ?: $DB_USER;
|
||||||
|
$DB_PASS = getenv('DB_PASS') ?: $DB_PASS;
|
||||||
|
$DB_ROOT = getenv('DB_ROOT_PASS') ?: $DB_ROOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of families from meta DB
|
||||||
|
try {
|
||||||
|
$meta = new PDO(
|
||||||
|
"mysql:host=$DB_HOST;dbname=househub_meta;charset=utf8mb4",
|
||||||
|
'root', $DB_ROOT,
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
$families = $meta->query("SELECT id FROM families")->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
exit("Cannot connect to meta DB: " . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($families as $family_id) {
|
||||||
|
$db_name = 'househub_f' . $family_id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO(
|
||||||
|
"mysql:host=$DB_HOST;dbname=$db_name;charset=utf8mb4",
|
||||||
|
$DB_USER, $DB_PASS,
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$pdo->query("SHOW TABLES LIKE 'pf_todos'")->fetchColumn()) continue;
|
||||||
|
|
||||||
|
$ws = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='todo_settings' AND reference_id='webhook_discord'");
|
||||||
|
$ws->execute();
|
||||||
|
$webhook = $ws->fetchColumn();
|
||||||
|
if (!$webhook) continue;
|
||||||
|
|
||||||
|
// Daily reminder: exact HH:MM match (same logic as the original todo app)
|
||||||
|
$now = date('H:i');
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT t.id, t.title, t.notes, t.due_time, t.priority,
|
||||||
|
l.name AS list_name, l.icon AS list_icon, l.color AS list_color
|
||||||
|
FROM pf_todos t
|
||||||
|
LEFT JOIN pf_todo_lists l ON l.id = t.list_id
|
||||||
|
WHERE t.due_time IS NOT NULL
|
||||||
|
AND TIME_FORMAT(t.due_time, '%H:%i') = ?
|
||||||
|
AND t.done = 0
|
||||||
|
");
|
||||||
|
$stmt->execute([$now]);
|
||||||
|
|
||||||
|
$priColors = ['high' => 0xef4444, 'medium' => 0xf59e0b, 'low' => 0x22c55e, 'none' => 0x3b82f6];
|
||||||
|
$priLabels = ['high' => '🔴 Haute', 'medium' => '🟡 Moyenne', 'low' => '🟢 Basse', 'none' => null];
|
||||||
|
|
||||||
|
foreach ($stmt->fetchAll() as $t) {
|
||||||
|
$time = substr($t['due_time'], 0, 5);
|
||||||
|
$priority = $t['priority'] ?? 'none';
|
||||||
|
$color = $priColors[$priority] ?? 0x3b82f6;
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
if ($t['list_name']) {
|
||||||
|
$fields[] = ['name' => 'Liste', 'value' => $t['list_icon'] . ' ' . $t['list_name'], 'inline' => true];
|
||||||
|
}
|
||||||
|
if ($priLabels[$priority]) {
|
||||||
|
$fields[] = ['name' => 'Priorité', 'value' => $priLabels[$priority], 'inline' => true];
|
||||||
|
}
|
||||||
|
$fields[] = ['name' => 'Heure', 'value' => '⏰ ' . $time, 'inline' => true];
|
||||||
|
|
||||||
|
$embed = [
|
||||||
|
'title' => '🔔 Rappel : ' . $t['title'],
|
||||||
|
'color' => $color,
|
||||||
|
'fields' => $fields,
|
||||||
|
'footer' => ['text' => 'HouseHub Todo'],
|
||||||
|
];
|
||||||
|
if (!empty($t['notes'])) {
|
||||||
|
$embed['description'] = $t['notes'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = ['username' => 'HouseHub Todo', 'embeds' => [$embed]];
|
||||||
|
|
||||||
|
$ch = curl_init($webhook);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/* ════════════════════════════════════════════════════════════════════
|
||||||
|
dark-mode.css — Dark mode overrides (all modules)
|
||||||
|
Chargé en dernier dans le head pour garantir la cascade.
|
||||||
|
════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ─── HOME PAGE ─────────────────────────────────────────────────────────────── */
|
||||||
|
[data-theme="dark"] body[data-page="home"] .pf-module-card {
|
||||||
|
background: rgba(22,27,34,.92) !important;
|
||||||
|
border: 1px solid var(--border-light) !important;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,.4) !important;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] body[data-page="home"] .pf-module-card:hover {
|
||||||
|
border-color: var(--primary) !important;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] body[data-page="home"] .pf-card-title { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] body[data-page="home"] .pf-card-desc { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] body[data-page="home"] .pf-card-cta { color: var(--primary); }
|
||||||
|
|
||||||
|
/* ─── SHARED / GLOBAL PATTERNS ──────────────────────────────────────────────── */
|
||||||
|
[data-theme="dark"] .pf-card { background: var(--bg-panel) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .pf-table { background: var(--bg-panel); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pf-table thead th { background: #1c2128 !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-table tbody td { background: var(--bg-panel); color: var(--text-main); border-color: rgba(48,54,61,.5); }
|
||||||
|
[data-theme="dark"] .pf-table tbody tr:hover td { background: rgba(255,255,255,.03); }
|
||||||
|
[data-theme="dark"] .pf-table tfoot,
|
||||||
|
[data-theme="dark"] .pf-table tfoot td { background: #1c2128; color: var(--text-main); border-color: rgba(48,54,61,.5); }
|
||||||
|
[data-theme="dark"] .pf-modal-content { background: var(--bg-panel) !important; border: 1px solid var(--border-light) !important; color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .pf-modal-title { color: var(--text-main) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-input,
|
||||||
|
[data-theme="dark"] .pf-filter-select,
|
||||||
|
[data-theme="dark"] .hol-year-select { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .pf-input::placeholder { color: #484f58 !important; }
|
||||||
|
[data-theme="dark"] .pf-btn.btn-secondary { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-btn.btn-secondary:hover { background: rgba(255,255,255,.06) !important; color: var(--text-main) !important; }
|
||||||
|
|
||||||
|
/* ─── BUDGET MODULE ─────────────────────────────────────────────────────────── */
|
||||||
|
[data-theme="dark"] .budget-view { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .budget-view h2,
|
||||||
|
[data-theme="dark"] .view-header h2 { color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .budget-tabs-container { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .tab-item { background: var(--bg-panel); color: var(--text-muted); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .tab-item:hover { background: rgba(255,255,255,.06); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .tab-item.active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; }
|
||||||
|
[data-theme="dark"] .owner-tabs { background: var(--bg-page); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .owner-tab { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .owner-tab.active { background: var(--primary) !important; color: #fff !important; }
|
||||||
|
[data-theme="dark"] .month-header-container { color: var(--text-main); }
|
||||||
|
/* sticky col: uses var(--bg-panel) already but th.sticky-col uses hardcoded #f8fafc */
|
||||||
|
[data-theme="dark"] th.sticky-col { background: #1c2128 !important; color: var(--text-muted) !important; }
|
||||||
|
[data-theme="dark"] .sticky-col { background: var(--bg-panel) !important; color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .month-actions .btn-icon-small { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .month-actions .btn-icon-small:hover { background: rgba(255,255,255,.06) !important; }
|
||||||
|
[data-theme="dark"] .savings-table .row-extres td { background: rgba(245,158,11,.08) !important; }
|
||||||
|
[data-theme="dark"] .nens-title.theme-pol { background: rgba(30,58,138,.2) !important; color: #93c5fd !important; border-color: #3b82f6 !important; }
|
||||||
|
[data-theme="dark"] .nens-title.theme-pep { background: rgba(20,83,45,.2) !important; color: #86efac !important; border-color: #22c55e !important; }
|
||||||
|
[data-theme="dark"] .nens-table.theme-pol thead th { background: rgba(30,58,138,.15) !important; color: #93c5fd !important; }
|
||||||
|
[data-theme="dark"] .nens-table.theme-pep thead th { background: rgba(20,83,45,.15) !important; color: #86efac !important; }
|
||||||
|
/* Specific input/selects in budget */
|
||||||
|
[data-theme="dark"] .budget-view input,
|
||||||
|
[data-theme="dark"] .budget-view select,
|
||||||
|
[data-theme="dark"] .budget-view textarea { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
|
||||||
|
/* sum selected highlight */
|
||||||
|
[data-theme="dark"] .sum-selected { background: rgba(59,130,246,.1) !important; }
|
||||||
|
|
||||||
|
/* ─── FAMILY CALENDAR MODULE ────────────────────────────────────────────────── */
|
||||||
|
[data-theme="dark"] .pf-family-calendar { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .fc-header-row { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .fc-view-controls { background: var(--bg-page) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .fc-view-button { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .fc-view-button--active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; }
|
||||||
|
[data-theme="dark"] .fc-month-nav-row { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .fc-month-nav-row h3 { color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .fc-nav-button { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .fc-nav-button:hover { background: rgba(255,255,255,.06) !important; }
|
||||||
|
/* Calendar table — lots of !important in source */
|
||||||
|
[data-theme="dark"] .fc-month-table { border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .fc-month-table th { background: #1c2128 !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .fc-month-table td { background: var(--bg-panel) !important; border-color: rgba(48,54,61,.5) !important; }
|
||||||
|
[data-theme="dark"] .fc-month-table td:hover:not(.fc-day--other-month) { background: rgba(255,255,255,.04) !important; }
|
||||||
|
[data-theme="dark"] .fc-day--other-month { background: var(--bg-page) !important; color: var(--text-muted) !important; }
|
||||||
|
[data-theme="dark"] .fc-month-calendar-wrapper,
|
||||||
|
[data-theme="dark"] .fc-month-balances { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .fc-minimal-balance-card { background: var(--bg-page) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .fc-min-chip { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .fc-burn-alert { background: rgba(239,68,68,.1) !important; color: #f87171 !important; }
|
||||||
|
/* Planning table (vue planning) */
|
||||||
|
[data-theme="dark"] #planningTable-wrapper { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] #planningTable thead th { background: #1c2128 !important; color: var(--text-muted) !important; }
|
||||||
|
[data-theme="dark"] #planningTable tbody td { border-color: rgba(48,54,61,.5) !important; }
|
||||||
|
[data-theme="dark"] #planningTable tbody td.col-sticky-mois,
|
||||||
|
[data-theme="dark"] #planningTable tbody td.col-sticky-sem { background: var(--bg-panel) !important; }
|
||||||
|
[data-theme="dark"] .col-month { background: var(--bg-panel) !important; color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .fc-week-nav-controls { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-btn-icon-text { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-btn-icon-text:hover { background: rgba(255,255,255,.06) !important; }
|
||||||
|
|
||||||
|
/* ─── HOLIDAYS MODULE ───────────────────────────────────────────────────────── */
|
||||||
|
[data-theme="dark"] .pf-holidays { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pf-holidays__titlebar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .hol-main-title { color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .hol-idea-card { background: var(--bg-panel) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; box-shadow: var(--shadow) !important; }
|
||||||
|
[data-theme="dark"] .hol-idea-card:hover { border-color: var(--primary) !important; }
|
||||||
|
[data-theme="dark"] .hol-idea-card input,
|
||||||
|
[data-theme="dark"] .hol-idea-card textarea,
|
||||||
|
[data-theme="dark"] .hol-idea-card select { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .hol-filters-row { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .hol-map-toggle { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .hol-map-toggle:hover { background: rgba(255,255,255,.06) !important; }
|
||||||
|
[data-theme="dark"] .hol-add-btn { background: var(--primary) !important; color: #fff !important; }
|
||||||
|
|
||||||
|
/* ─── GIFT-LIST MODULE ──────────────────────────────────────────────────────── */
|
||||||
|
[data-theme="dark"] .pf-gift-list { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pf-gift-list h1 { color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .cl-titlebar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .cl-view-btn { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .cl-view-btn.is-active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; }
|
||||||
|
[data-theme="dark"] .pf-filter-bar { background: var(--bg-page) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-filter-select { background: #1c2128 !important; color: var(--text-main) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-child-section { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-child-header { background: #1c2128 !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-child-header h3 { color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .pf-child-totals-bar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-summary-pill { background: var(--bg-page) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .pf-gift-card-compact { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .pf-gift-card-compact:hover { border-color: var(--primary) !important; }
|
||||||
|
[data-theme="dark"] .pf-gift-title { color: var(--text-main) !important; }
|
||||||
|
[data-theme="dark"] .pf-gift-footer { border-color: var(--border-light) !important; }
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
househub:
|
||||||
|
build: .
|
||||||
|
container_name: househub
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
househub-db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DB_HOST: househub-db
|
||||||
|
DB_NAME: ${DB_NAME:-househub}
|
||||||
|
DB_USER: ${DB_USER:-househub}
|
||||||
|
DB_PASS: ${DB_PASS:-changeme}
|
||||||
|
# Clé applicative pour chiffrer les secrets (iCloud, etc.). Optionnel si absent : fichier data/.hh_app_secret ou dérivation DB.
|
||||||
|
APP_SECRET_KEY: ${APP_SECRET_KEY:-}
|
||||||
|
volumes:
|
||||||
|
- househub_uploads:/uploads
|
||||||
|
- /opt/container/househub:/var/www/html
|
||||||
|
networks:
|
||||||
|
- househub_net
|
||||||
|
- proxy
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.househub.rule=Host(`househub.nas.percolouco.com`)"
|
||||||
|
- "traefik.http.routers.househub.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.househub.tls=true"
|
||||||
|
- "traefik.http.routers.househub.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.househub.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
househub-db:
|
||||||
|
image: mariadb:11
|
||||||
|
container_name: househub-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS:-rootchangeme}
|
||||||
|
MYSQL_DATABASE: househub_meta
|
||||||
|
MYSQL_USER: ${DB_USER:-househub}
|
||||||
|
MYSQL_PASSWORD: ${DB_PASS:-changeme}
|
||||||
|
volumes:
|
||||||
|
- househub_db_data:/var/lib/mysql
|
||||||
|
- ./docker/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
networks:
|
||||||
|
- househub_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
househub_db_data:
|
||||||
|
househub_uploads:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
househub_net:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<VirtualHost *:80>
|
||||||
|
DocumentRoot /var/www/html
|
||||||
|
<Directory /var/www/html>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
Options -Indexes
|
||||||
|
</Directory>
|
||||||
|
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||||
|
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||||
|
</VirtualHost>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- Initialisation HouseHub — Meta DB + permissions
|
||||||
|
-- Exécuté automatiquement au premier démarrage MariaDB
|
||||||
|
|
||||||
|
USE househub_meta;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS families (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
db_name VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
invite_code VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
is_active TINYINT(1) DEFAULT 1,
|
||||||
|
enabled_modules JSON DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS 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,
|
||||||
|
family_id INT DEFAULT NULL,
|
||||||
|
is_admin TINYINT(1) DEFAULT 0,
|
||||||
|
is_active TINYINT(1) DEFAULT 1,
|
||||||
|
lang VARCHAR(5) DEFAULT 'fr',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (family_id) REFERENCES families(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_calendar_integrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL DEFAULT 'icloud_caldav',
|
||||||
|
username VARCHAR(255) NOT NULL,
|
||||||
|
secret_encrypted TEXT NOT NULL,
|
||||||
|
dav_principal_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
status VARCHAR(30) DEFAULT 'connected',
|
||||||
|
last_sync_at DATETIME DEFAULT NULL,
|
||||||
|
calendar_prefs_json TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_user_provider (user_id, provider),
|
||||||
|
CONSTRAINT fk_calendar_integration_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Donner au user applicatif le droit de créer des DBs famille
|
||||||
|
GRANT ALL PRIVILEGES ON `househub_f%`.* TO 'househub'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
upload_max_filesize = 300M
|
||||||
|
post_max_size = 310M
|
||||||
|
memory_limit = 512M
|
||||||
|
max_execution_time = 120
|
||||||
|
max_input_time = 120
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
-- HouseHub — Schéma MySQL (Modèle pour une base famille)
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- ─── Configuration du foyer ───────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
currency VARCHAR(10) NOT NULL DEFAULT '€',
|
||||||
|
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
|
||||||
|
csv_mapping TEXT DEFAULT NULL,
|
||||||
|
cares_modes TEXT DEFAULT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '€', 'C');
|
||||||
|
|
||||||
|
-- ────────────────────────────────────────────────────────────
|
||||||
|
-- Paramètres dynamiques des Modules (Key-Value)
|
||||||
|
-- ────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS `pf_settings` (
|
||||||
|
`setting_key` varchar(50) NOT NULL,
|
||||||
|
`setting_value` text DEFAULT NULL,
|
||||||
|
`module` varchar(50) NOT NULL,
|
||||||
|
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`setting_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `pf_settings` (`setting_key`, `setting_value`, `module`) VALUES
|
||||||
|
('calendar_default_view', 'month', 'calendar'),
|
||||||
|
('calendar_first_day', '1', 'calendar'),
|
||||||
|
('calendar_working_hours', '08:00-19:00', 'calendar'),
|
||||||
|
('budget_start_day', '1', 'budget'),
|
||||||
|
('budget_default_tab', 'dépenses', 'budget'),
|
||||||
|
('travel_default_transport', 'car', 'voyage'),
|
||||||
|
('travel_default_fuel_price', '1.85', 'voyage'),
|
||||||
|
('gifts_hide_purchased', '0', 'cadeaux'),
|
||||||
|
('gifts_default_sort', 'person', 'cadeaux'),
|
||||||
|
('gifts_budget_alert', '500', 'cadeaux');
|
||||||
|
|
||||||
|
-- ─── Utilisateurs (Legacy) ────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(100) NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Personnes ────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS `pf_people` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(100) NOT NULL,
|
||||||
|
`user_id` int(11) DEFAULT NULL,
|
||||||
|
`role` varchar(50) DEFAULT NULL,
|
||||||
|
`care_modes` varchar(255) DEFAULT NULL,
|
||||||
|
`color` varchar(7) DEFAULT '#0891b2',
|
||||||
|
`is_active` TINYINT(1) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Calendrier familial ──────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_events (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event_date DATE NOT NULL,
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
person_id INT NOT NULL,
|
||||||
|
duration DECIMAL(4,2) DEFAULT 1.0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_leaves (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
person_id INT NOT NULL,
|
||||||
|
leave_type VARCHAR(50) NOT NULL,
|
||||||
|
leave_date DATE NOT NULL,
|
||||||
|
duration DECIMAL(4,2) DEFAULT 1.0,
|
||||||
|
UNIQUE KEY uq_leave (person_id, leave_type, leave_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_leave_balances (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
person_id INT NOT NULL,
|
||||||
|
leave_type VARCHAR(50) NOT NULL,
|
||||||
|
initial_balance DECIMAL(6,2) DEFAULT 0,
|
||||||
|
balance_year INT NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_leave_snapshots (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
person_id INT NOT NULL,
|
||||||
|
leave_type VARCHAR(50) NOT NULL,
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
remaining_balance DECIMAL(6,2) DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_person_leave_meta (
|
||||||
|
id INT(11) AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
person_id INT(11) NOT NULL,
|
||||||
|
leave_type VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||||
|
method VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'FIXED',
|
||||||
|
allowance DECIMAL(5,2) DEFAULT 0.00,
|
||||||
|
anniversary_date DATE DEFAULT NULL,
|
||||||
|
INDEX (person_id),
|
||||||
|
INDEX (leave_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ─── Types de Congés (Référentiel) ─────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_leave_types (
|
||||||
|
id INT(11) AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(20) COLLATE utf8mb4_general_ci NOT NULL,
|
||||||
|
label VARCHAR(100) COLLATE utf8mb4_general_ci NOT NULL,
|
||||||
|
default_allowance DECIMAL(5,2) DEFAULT 0.00,
|
||||||
|
reset_month INT(11) DEFAULT 1,
|
||||||
|
allow_carry_over TINYINT(1) DEFAULT 0,
|
||||||
|
UNIQUE KEY code (code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO pf_leave_types (code, label, default_allowance, reset_month, allow_carry_over) VALUES
|
||||||
|
('CP', 'Congés Payés', 25.00, 6, 0);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_weeks (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL,
|
||||||
|
week_iso_year INT NOT NULL,
|
||||||
|
week_iso_number INT NOT NULL,
|
||||||
|
week_label VARCHAR(20),
|
||||||
|
month INT,
|
||||||
|
month_name VARCHAR(20),
|
||||||
|
week_start_date DATE,
|
||||||
|
mon_date DATE, tue_date DATE, wed_date DATE,
|
||||||
|
thu_date DATE, fri_date DATE, sat_date DATE, sun_date DATE,
|
||||||
|
UNIQUE KEY uq_week (year, week_iso_year, week_iso_number)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Budget ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- NOUVEAU : Table des comptes bancaires
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_bank_accounts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
account_type ENUM('checking', 'savings') DEFAULT 'checking',
|
||||||
|
owner_person_id INT DEFAULT NULL,
|
||||||
|
is_default TINYINT(1) DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (owner_person_id) REFERENCES pf_people(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_budget_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
amount DECIMAL(10,2) DEFAULT 0,
|
||||||
|
category VARCHAR(100),
|
||||||
|
type VARCHAR(50),
|
||||||
|
payment_day INT DEFAULT NULL,
|
||||||
|
is_estimate TINYINT(1) DEFAULT 0,
|
||||||
|
reg_month VARCHAR(7) DEFAULT NULL,
|
||||||
|
mapping_keywords TEXT DEFAULT NULL,
|
||||||
|
holiday_id INT DEFAULT NULL,
|
||||||
|
is_checked TINYINT(1) DEFAULT 0,
|
||||||
|
sort_order INT DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_expenses (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
date_exp DATE NOT NULL,
|
||||||
|
gestion_month VARCHAR(10) NOT NULL,
|
||||||
|
category VARCHAR(100),
|
||||||
|
label VARCHAR(255),
|
||||||
|
amount DECIMAL(10,2) DEFAULT 0,
|
||||||
|
import_ref VARCHAR(255) DEFAULT NULL,
|
||||||
|
budget_item_id INT DEFAULT NULL,
|
||||||
|
holiday_id INT DEFAULT NULL,
|
||||||
|
salary_id INT DEFAULT NULL -- 🔥 AJOUT : Lien vers le salaire configuré
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `pf_expected_expenses` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`title` VARCHAR(150) NOT NULL,
|
||||||
|
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`expected_date` DATE NOT NULL,
|
||||||
|
`is_paid` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_expected_date` (`expected_date`),
|
||||||
|
INDEX `idx_is_paid` (`is_paid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_alloc_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
target DECIMAL(10,2) DEFAULT 0.00,
|
||||||
|
transfer_dest VARCHAR(50) DEFAULT NULL,
|
||||||
|
holiday_id INT DEFAULT NULL,
|
||||||
|
sort_order INT DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_alloc_values (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
month_date VARCHAR(10) NOT NULL,
|
||||||
|
cat_id INT NOT NULL,
|
||||||
|
person_id INT NOT NULL,
|
||||||
|
amount DECIMAL(10,2) DEFAULT 0.00,
|
||||||
|
UNIQUE KEY uq_alloc_person (month_date, cat_id, person_id),
|
||||||
|
FOREIGN KEY (person_id) REFERENCES pf_people(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_savings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
owner VARCHAR(50) NOT NULL,
|
||||||
|
month_date VARCHAR(10) NOT NULL,
|
||||||
|
category VARCHAR(100) NOT NULL,
|
||||||
|
amount DECIMAL(10,2) DEFAULT 0,
|
||||||
|
holiday_id INT DEFAULT NULL,
|
||||||
|
UNIQUE KEY uq_savings (owner, month_date, category)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_bank_snapshots (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
amount DECIMAL(12,2) DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_salary_config (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL,
|
||||||
|
person VARCHAR(50) NOT NULL,
|
||||||
|
salary DECIMAL(10,2) DEFAULT 0,
|
||||||
|
mensualite DECIMAL(10,2) DEFAULT 0,
|
||||||
|
frais_func DECIMAL(10,2) DEFAULT 0,
|
||||||
|
eco_perso DECIMAL(10,2) DEFAULT 0,
|
||||||
|
eco_family DECIMAL(10,2) DEFAULT 0,
|
||||||
|
UNIQUE KEY uq_salary (year, person)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_import_rules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
keyword VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
category VARCHAR(100) NOT NULL,
|
||||||
|
budget_item_id INT(11) NULL DEFAULT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `pf_advances` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`advance_date` date NOT NULL,
|
||||||
|
`payer` varchar(50) NOT NULL,
|
||||||
|
`description` varchar(255) NOT NULL,
|
||||||
|
`amount` decimal(10,2) DEFAULT 0.00,
|
||||||
|
`from_savings` tinyint(1) DEFAULT 0,
|
||||||
|
`is_resolved` tinyint(1) DEFAULT 0,
|
||||||
|
`created_at` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ─── Budget Catégories (Dynamiques) ─────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_budget_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
label VARCHAR(100) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
color VARCHAR(20) DEFAULT '#ccc',
|
||||||
|
icon VARCHAR(20) DEFAULT '?'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- Insertion des catégories de base pour toute nouvelle famille
|
||||||
|
INSERT IGNORE INTO pf_budget_categories (code, label, type, color, icon) VALUES
|
||||||
|
('INCOME', 'Revenus', 'Income', '#10b981', '💵'),
|
||||||
|
('FIXED', 'Charges Fixes', 'Expense', '#ef4444', '🏢');
|
||||||
|
|
||||||
|
-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_notes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
note_type VARCHAR(100) NOT NULL,
|
||||||
|
reference_id VARCHAR(100) NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
UNIQUE KEY uq_note (note_type, reference_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_memo_notes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
content LONGTEXT DEFAULT '',
|
||||||
|
tags VARCHAR(1000) DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FULLTEXT KEY ft_notes (title, content, tags)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_memo_attachments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
note_id INT NOT NULL,
|
||||||
|
type ENUM('image','file','url') NOT NULL DEFAULT 'file',
|
||||||
|
filename VARCHAR(255) DEFAULT NULL,
|
||||||
|
original_name VARCHAR(255) DEFAULT NULL,
|
||||||
|
url TEXT DEFAULT NULL,
|
||||||
|
label VARCHAR(255) DEFAULT NULL,
|
||||||
|
size INT DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Voyages / Holidays ───────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_holidays (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
period_hint VARCHAR(100) DEFAULT NULL,
|
||||||
|
start_date DATE DEFAULT NULL,
|
||||||
|
end_date DATE DEFAULT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'draft',
|
||||||
|
budget_food DECIMAL(10,2) DEFAULT 0,
|
||||||
|
budget_extra DECIMAL(10,2) DEFAULT 0,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
vehicle_id INT DEFAULT NULL,
|
||||||
|
return_step_id INT DEFAULT NULL,
|
||||||
|
image_url VARCHAR(500) DEFAULT NULL,
|
||||||
|
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_holidays_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
holiday_id INT NOT NULL,
|
||||||
|
category VARCHAR(100),
|
||||||
|
name VARCHAR(255),
|
||||||
|
amount DECIMAL(10,2) DEFAULT 0,
|
||||||
|
is_paid TINYINT(1) DEFAULT 0,
|
||||||
|
location_name VARCHAR(255) DEFAULT NULL,
|
||||||
|
lat DECIMAL(10,7) DEFAULT NULL,
|
||||||
|
lng DECIMAL(10,7) DEFAULT NULL,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
item_date DATE DEFAULT NULL,
|
||||||
|
item_time TIME DEFAULT NULL,
|
||||||
|
step_type VARCHAR(20) DEFAULT 'stop',
|
||||||
|
step_start_date DATE DEFAULT NULL,
|
||||||
|
step_end_date DATE DEFAULT NULL,
|
||||||
|
duration INT DEFAULT NULL,
|
||||||
|
expense_context VARCHAR(20) DEFAULT NULL,
|
||||||
|
FOREIGN KEY (holiday_id) REFERENCES pf_holidays(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_holidays_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
holiday_id INT NOT NULL,
|
||||||
|
category VARCHAR(100),
|
||||||
|
name VARCHAR(255),
|
||||||
|
amount DECIMAL(10,2) DEFAULT 0,
|
||||||
|
is_paid TINYINT(1) DEFAULT 0,
|
||||||
|
location_name VARCHAR(255) DEFAULT NULL,
|
||||||
|
lat DECIMAL(10,7) DEFAULT NULL,
|
||||||
|
lng DECIMAL(10,7) DEFAULT NULL,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
item_date DATE DEFAULT NULL,
|
||||||
|
item_time TIME DEFAULT NULL,
|
||||||
|
step_start_date DATE DEFAULT NULL,
|
||||||
|
step_end_date DATE DEFAULT NULL,
|
||||||
|
duration INT DEFAULT NULL,
|
||||||
|
is_return TINYINT(1) DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_holidays_ideas (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
period_hint VARCHAR(100) DEFAULT NULL,
|
||||||
|
start_date DATE DEFAULT NULL,
|
||||||
|
end_date DATE DEFAULT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'idea',
|
||||||
|
budget_food DECIMAL(10,2) DEFAULT 0,
|
||||||
|
budget_extra DECIMAL(10,2) DEFAULT 0,
|
||||||
|
notes TEXT DEFAULT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `pf_holidays_attachments` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`holiday_id` INT NOT NULL,
|
||||||
|
`item_id` INT DEFAULT NULL,
|
||||||
|
`file_name` VARCHAR(255) NOT NULL,
|
||||||
|
`file_path` VARCHAR(255) NOT NULL,
|
||||||
|
`uploaded_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
CONSTRAINT `fk_holiday_attachment` FOREIGN KEY (`holiday_id`) REFERENCES `pf_holidays`(`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_geocode_cache (
|
||||||
|
q_hash CHAR(64) PRIMARY KEY,
|
||||||
|
q VARCHAR(255),
|
||||||
|
lat DECIMAL(10,7),
|
||||||
|
lng DECIMAL(10,7),
|
||||||
|
display_name TEXT,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Cadeaux ──────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_gifts (
|
||||||
|
id INT(11) AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT(11) NOT NULL,
|
||||||
|
adult_name VARCHAR(100) NOT NULL,
|
||||||
|
payer_name VARCHAR(100) DEFAULT NULL,
|
||||||
|
child_name VARCHAR(100) NOT NULL,
|
||||||
|
occasion VARCHAR(50) NOT NULL,
|
||||||
|
gift_description TEXT NOT NULL,
|
||||||
|
product_link VARCHAR(500) DEFAULT NULL,
|
||||||
|
amount DECIMAL(8,2) DEFAULT 0.00
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_gift_occasions (
|
||||||
|
id INT(11) AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
icon VARCHAR(20) DEFAULT '🎁',
|
||||||
|
month_date VARCHAR(5) DEFAULT NULL,
|
||||||
|
is_active TINYINT(1) DEFAULT 1,
|
||||||
|
sort_order INT DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO pf_gift_occasions (code, name, icon, month_date, is_active, sort_order) VALUES
|
||||||
|
('XMAS', 'Noël', '🎄', '12-25', 1, 1),
|
||||||
|
('BIRTHDAY', 'Anniversaire', '🎂', NULL, 1, 2),
|
||||||
|
('OTHER', 'Autre', '🎁', NULL, 1, 99);
|
||||||
|
|
||||||
|
-- Table des règles (Qui offre à Qui)
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_gift_rules (
|
||||||
|
id INT(11) AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
adult_person_id INT(11) NOT NULL,
|
||||||
|
child_person_id INT(11) NOT NULL,
|
||||||
|
occasion_id INT(11) NOT NULL,
|
||||||
|
INDEX (adult_person_id),
|
||||||
|
INDEX (child_person_id),
|
||||||
|
INDEX (occasion_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ─── Garage Manager ───────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_vehicles (
|
||||||
|
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',
|
||||||
|
consumption decimal(4,2) DEFAULT NULL COMMENT 'Consommation L/100km',
|
||||||
|
color VARCHAR(50) DEFAULT NULL,
|
||||||
|
purchase_date DATE DEFAULT NULL,
|
||||||
|
purchase_price DECIMAL(10,2) DEFAULT NULL,
|
||||||
|
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;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_maintenances (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vehicle_id INT NOT NULL,
|
||||||
|
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;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_parts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
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 ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_events (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
family_id INT NOT NULL,
|
||||||
|
created_by_user_id INT NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT DEFAULT NULL,
|
||||||
|
location VARCHAR(255) DEFAULT NULL,
|
||||||
|
start_at DATETIME NOT NULL,
|
||||||
|
end_at DATETIME NOT NULL,
|
||||||
|
is_all_day TINYINT(1) DEFAULT 0,
|
||||||
|
timezone VARCHAR(64) DEFAULT 'Europe/Paris',
|
||||||
|
rrule VARCHAR(500) DEFAULT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'confirmed',
|
||||||
|
external_uid VARCHAR(255) DEFAULT NULL,
|
||||||
|
sync_state VARCHAR(30) DEFAULT 'pending_push',
|
||||||
|
deleted_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_external_uid (external_uid)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
calendar_event_id INT NOT NULL,
|
||||||
|
external_uid VARCHAR(255) NOT NULL,
|
||||||
|
external_etag VARCHAR(255) DEFAULT NULL,
|
||||||
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
external_href VARCHAR(2048) DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_calendar_event (calendar_event_id),
|
||||||
|
UNIQUE KEY uq_external_link (external_uid)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── PrintVault ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_pv_models (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
category VARCHAR(100) DEFAULT 'Non classé',
|
||||||
|
tags VARCHAR(500) DEFAULT '',
|
||||||
|
file_type VARCHAR(20) NOT NULL,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
file_size INT DEFAULT 0,
|
||||||
|
dim_x DECIMAL(10,3) DEFAULT 0,
|
||||||
|
dim_y DECIMAL(10,3) DEFAULT 0,
|
||||||
|
dim_z DECIMAL(10,3) DEFAULT 0,
|
||||||
|
volume DECIMAL(10,3) DEFAULT 0,
|
||||||
|
gcode_time VARCHAR(50) DEFAULT '',
|
||||||
|
gcode_filament VARCHAR(50) DEFAULT '',
|
||||||
|
gcode_nozzle VARCHAR(20) DEFAULT '',
|
||||||
|
gcode_bed VARCHAR(20) DEFAULT '',
|
||||||
|
thumb VARCHAR(255) DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_pv_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
color VARCHAR(20) DEFAULT '#8b5cf6'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO pf_pv_categories (name, color) VALUES
|
||||||
|
('Non classé','#64748b'),('Déco','#ec4899'),('Fonctionnel','#3b82f6'),
|
||||||
|
('Mécanique','#f59e0b'),('Jouets','#10b981'),('Outils','#ef4444'),
|
||||||
|
('Architecture','#8b5cf6'),('Art','#06b6d4');
|
||||||
|
|
||||||
|
-- ─── Planka ───────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_planka_config (
|
||||||
|
id INT NOT NULL DEFAULT 1,
|
||||||
|
project_id VARCHAR(32),
|
||||||
|
admin_token TEXT,
|
||||||
|
token_expires_at DATETIME,
|
||||||
|
active_board_id VARCHAR(32),
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
@@ -1,135 +1,482 @@
|
|||||||
<?php
|
<?php
|
||||||
// Active l'affichage des erreurs pour le développement
|
// modules/family-calendar/family-calendar.php
|
||||||
ini_set('display_errors', 1);
|
ini_set('display_errors', 1);
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
// On se connecte à la base de données
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
require __DIR__ . '/includes/db.php';
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
// --- On récupère TOUS les événements sauvegardés en base ---
|
// 1. Récupération de toutes les personnes
|
||||||
$stmt_events = $pdo->query("SELECT * FROM pf_events");
|
$stmtPeople = $pdo->query("SELECT id, name, user_id, role, color, care_modes FROM pf_people WHERE is_active = 1 ORDER BY id ASC");
|
||||||
$dbEvents = $stmt_events->fetchAll();
|
$familyPeople = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// --- Configuration de la page ---
|
$parents = [];
|
||||||
$pageTitle = "PachaFamily - Family Calendar";
|
$kids = [];
|
||||||
|
$helpers = [];
|
||||||
|
|
||||||
|
foreach ($familyPeople as $p) {
|
||||||
|
$role = strtolower($p['role'] ?? '');
|
||||||
|
if ($role === 'parent') {
|
||||||
|
$parents[] = $p;
|
||||||
|
} elseif ($role === 'enfant' || $role === 'child') {
|
||||||
|
$p['modes'] = json_decode($p['care_modes'] ?? '[]', true) ?: [];
|
||||||
|
$kids[] = $p;
|
||||||
|
} elseif ($role === 'helper' || $role === 'nounou') {
|
||||||
|
$helpers[] = $p; // 🟢 On stocke les intervenants
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Modes de garde (globaux du foyer, pour synchroniser avec le JS)
|
||||||
|
$stmtFoyer = $pdo->query("SELECT care_modes, zone_scolaire FROM pf_foyer_settings LIMIT 1");
|
||||||
|
$foyerData = $stmtFoyer->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$activeCareModes = json_decode($foyerData['care_modes'] ?? '[]', true);
|
||||||
|
if (!is_array($activeCareModes)) $activeCareModes = [];
|
||||||
|
|
||||||
|
// 3. Matrice de congés dynamique (pour les en-têtes et la modale)
|
||||||
|
$stmtLeaves = $pdo->query("SELECT person_id, leave_type FROM pf_person_leave_meta ORDER BY id ASC");
|
||||||
|
$dbLeaves = $stmtLeaves->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$leaveMatrix = [];
|
||||||
|
$allLeaveTypes = [];
|
||||||
|
foreach ($dbLeaves as $l) {
|
||||||
|
$leaveMatrix[$l['person_id']][] = $l['leave_type'];
|
||||||
|
$allLeaveTypes[$l['leave_type']] = true;
|
||||||
|
}
|
||||||
|
$allLeaveTypes = array_keys($allLeaveTypes);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// VÉRIFICATIONS D'INTÉGRITÉ (ALERTES)
|
||||||
|
// ==========================================
|
||||||
|
$calendarAlerts = [];
|
||||||
|
|
||||||
|
// 1. Aucun membre adulte/parent configuré
|
||||||
|
if (empty($parents)) {
|
||||||
|
$calendarAlerts[] = "<strong>Aucun adulte configuré.</strong> Rendez-vous dans les Paramètres Généraux de HouseHub pour ajouter les membres du foyer.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Enfants présents, mais aucun mode de garde global défini
|
||||||
|
if (!empty($kids) && empty($activeCareModes)) {
|
||||||
|
$calendarAlerts[] = "<strong>Modes de garde manquants.</strong> Vous avez des enfants, mais aucun mode de garde n'est défini. Cliquez sur l'icône ⚙️ (Paramètres du calendrier) pour les ajouter.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Référentiel des types de congés vide
|
||||||
|
try {
|
||||||
|
$countLeaveTypes = $pdo->query("SELECT COUNT(*) FROM pf_leave_types")->fetchColumn();
|
||||||
|
if ($countLeaveTypes == 0) {
|
||||||
|
$calendarAlerts[] = "<strong>Types de congés manquants.</strong> Le catalogue des congés (CP, RTT...) est vide. Ajoutez-les dans les paramètres généraux.";
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Si la table n'existe pas encore
|
||||||
|
$calendarAlerts[] = "<strong>Table des congés manquante.</strong> Veuillez mettre à jour la base de données.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Zone scolaire manquante (si tu l'as stockée dans pf_foyer_settings)
|
||||||
|
$zoneScolaire = $foyerData['zone_scolaire'] ?? null;
|
||||||
|
if (empty($zoneScolaire)) {
|
||||||
|
$calendarAlerts[] = "<strong>Zone scolaire non définie.</strong> Cliquez sur l'icône ⚙️ pour configurer votre zone afin d'afficher les vacances scolaires.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$pageTitle = tr('fc_page_title');
|
||||||
$activePage = "family-calendar";
|
$activePage = "family-calendar";
|
||||||
|
$mainClass = "pf-family-calendar";
|
||||||
|
$pageCss = "/modules/family-calendar/family-calendar.css";
|
||||||
|
|
||||||
require __DIR__ . '/header.php';
|
require __DIR__ . '/header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<!-- ===================================================================== -->
|
<div class="pf-container" style="max-width:100%; padding:0;">
|
||||||
<!-- INJECTION DES DONNÉES DU SERVEUR VERS JAVASCRIPT -->
|
<div class="fc-header-row" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; margin-bottom: 24px;">
|
||||||
<!-- Cette variable `serverData` sera lue par le script JS au démarrage. -->
|
|
||||||
<!-- ===================================================================== -->
|
|
||||||
<script>
|
|
||||||
const serverData = <?php echo json_encode($dbEvents, JSON_NUMERIC_CHECK); ?>;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h1>Family Calendar</h1>
|
<!-- Bloc Titre + Roue crantée -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
<h1 style="margin: 0;"><?= tr('fc_main_header') ?></h1>
|
||||||
|
<button id="btnOpenCalendarSettings" class="btn-settings-gear" title="Paramètres du calendrier" style="font-size: 1.4rem; padding: 4px;">
|
||||||
|
⚙️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boutons d'actions -->
|
||||||
<!-- ===================================================================== -->
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
<!-- PANNEAU DE CONTRÔLE : Légende, Récapitulatif et Vacances -->
|
<button id="btnOpenSnapshotModal" class="pf-btn-icon-text">
|
||||||
<!-- ===================================================================== -->
|
<span class="icon">⚖️</span>
|
||||||
<section class="pf-section pf-section--panel">
|
<span class="text"><?= tr('fc_btn_correct_balances') ?></span>
|
||||||
<div class="pf-flex pf-flex--wrap pf-gap-lg">
|
</button>
|
||||||
|
<button id="btnOpenHolidays" class="pf-btn-icon-text">
|
||||||
<!-- LÉGENDE -->
|
<span class="icon">🏖️</span>
|
||||||
<div class="pf-card pf-card--small">
|
<span class="text"><?= tr('fc_btn_school_holidays') ?></span>
|
||||||
<h2 class="pf-card-title">Légende</h2>
|
</button>
|
||||||
<div class="pf-card-body">
|
|
||||||
<div class="pf-legend-item">
|
|
||||||
<div class="pf-legend-color fc-day--school-holiday"></div>
|
|
||||||
<span>Vacances scolaires</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pf-legend-item">
|
|
||||||
<div class="pf-legend-color fc-day--public-holiday"></div>
|
|
||||||
<span>Jour férié</span>
|
|
||||||
</div>
|
|
||||||
<div class="pf-legend-item">
|
|
||||||
<div class="pf-legend-color fc-day--off-carole"></div>
|
|
||||||
<span>Off Carole</span>
|
|
||||||
</div>
|
|
||||||
<div class="pf-legend-item">
|
|
||||||
<div class="pf-legend-color fc-day--extra-off-carole"></div>
|
|
||||||
<span>Extra Off Carole</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RÉCAPITULATIF ANNUEL -->
|
<?php if (!empty($calendarAlerts)): ?>
|
||||||
<div class="pf-card pf-card--small">
|
<div class="pf-alert pf-alert-warning" style="background: var(--bg-warning, #fffbeb); border: 1px solid var(--border-warning, #fcd34d); color: var(--text-warning, #92400e); padding: 16px; border-radius: 8px; margin-bottom: 24px; display: flex; gap: 12px; align-items: flex-start;">
|
||||||
<h2 class="pf-card-title">Récapitulatif annuel</h2>
|
<div style="font-size: 1.5rem; line-height: 1;">⚠️</div>
|
||||||
<div class="pf-card-body" id="globalSummary">
|
<div>
|
||||||
<!-- Rempli par le JavaScript -->
|
<h4 style="margin: 0 0 8px 0; font-size: 1.05rem;">Configuration incomplète</h4>
|
||||||
</div>
|
<ul style="margin: 0; padding-left: 18px; font-size: 0.95rem; line-height: 1.5;">
|
||||||
|
<?php foreach ($calendarAlerts as $alert): ?>
|
||||||
|
<li style="margin-bottom: 4px;"><?= $alert ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div id="modalHolidays" class="pf-modal">
|
||||||
|
<div class="pf-modal-content">
|
||||||
|
<div class="pf-modal-header">
|
||||||
|
<h3 class="pf-modal-title">
|
||||||
|
<?= tr('fc_modal_holidays_title') ?>
|
||||||
|
<?php if (defined('ZONE_SCOLAIRE') && ZONE_SCOLAIRE !== 'Autre') echo '(Zone ' . htmlspecialchars(ZONE_SCOLAIRE) . ')'; ?>
|
||||||
|
</h3>
|
||||||
|
<button id="btnCloseHolidays" class="pf-modal-close" onclick="document.getElementById('modalHolidays').classList.remove('active')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="pf-modal-body" style="padding:0;">
|
||||||
|
<div style="width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||||
|
<table id="schoolHolidaysTable" class="fc-holidays-table" style="min-width: 400px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?= tr('fc_col_period') ?></th>
|
||||||
|
<th><?= tr('fc_col_from') ?></th>
|
||||||
|
<th><?= tr('fc_col_to') ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- VACANCES SCOLAIRES -->
|
<div id="modalSnapshot" class="pf-modal">
|
||||||
<div class="pf-card pf-card--small pf-card--wide">
|
<div class="pf-modal-content">
|
||||||
<h2 class="pf-card-title">Vacances scolaires - Zone C (2025-2026)</h2>
|
<div class="pf-modal-header">
|
||||||
<div class="pf-card-body">
|
<h3 class="pf-modal-title">📸 <?= tr('fc_modal_snap_title') ?></h3>
|
||||||
<div class="pf-table-wrapper">
|
<button type="button" id="btnCloseSnapshot" class="pf-modal-close" onclick="document.getElementById('modalSnapshot').classList.remove('open'); document.body.classList.remove('no-scroll');">×</button>
|
||||||
<table id="schoolHolidaysTable" class="fc-holidays-table">
|
</div>
|
||||||
<thead>
|
<div class="pf-modal-body">
|
||||||
<tr>
|
<form id="formSnapshot">
|
||||||
<th>Période</th>
|
<div class="pf-form-row">
|
||||||
<th>Du</th>
|
<div class="pf-form-group">
|
||||||
<th>Au</th>
|
<label class="pf-label"><?= tr('fc_label_person') ?></label>
|
||||||
<th>Zones</th>
|
<select id="snapPerson" class="pf-input" required>
|
||||||
</tr>
|
<option value="" disabled selected>-- <?= tr('fc_choose_person') ?> --</option>
|
||||||
</thead>
|
<?php foreach ($parents as $person): ?>
|
||||||
<tbody>
|
<option value="<?= htmlspecialchars($person['id']) ?>">
|
||||||
<!-- Rempli par JS -->
|
<?= htmlspecialchars($person['name']) ?>
|
||||||
</tbody>
|
</option>
|
||||||
</table>
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label"><?= tr('fc_label_leave_type') ?></label>
|
||||||
|
<select id="snapType" class="pf-input" required>
|
||||||
|
<?php foreach ($allLeaveTypes as $type): ?>
|
||||||
|
<option value="<?= htmlspecialchars($type) ?>"><?= htmlspecialchars($type) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label"><?= tr('fc_label_apply_date') ?></label>
|
||||||
|
<input type="date" id="snapDate" class="pf-input" required title="<?= tr('fc_snap_date_hint') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label"><?= tr('fc_label_remaining_balance') ?></label>
|
||||||
|
<div style="display:flex; align-items:center;">
|
||||||
|
<input type="number" step="0.5" id="snapBalance" class="pf-input" placeholder="<?= tr('fc_placeholder_balance') ?>" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--pf-primary);" required>
|
||||||
|
<span style="background: var(--pf-bg-lighter); border: 1px solid var(--pf-border); padding: 0 12px; height: 42px; display: flex; align-items: center; border-radius: 0 8px 8px 0; color: var(--pf-text-muted); font-size: 0.9rem; font-weight: 600;">
|
||||||
|
<?= tr('fc_unit_days') ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="pf-modal-footer">
|
||||||
|
<button type="button" class="pf-btn pf-btn-secondary" onclick="document.getElementById('modalSnapshot').classList.remove('open'); document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" form="formSnapshot" class="pf-btn pf-btn-primary"><?= tr('fc_btn_save_snap') ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modalCalendarSettings" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="max-width: 650px; width: 95%;">
|
||||||
|
|
||||||
|
<div class="pf-modal-header">
|
||||||
|
<h3 class="pf-modal-title">⚙️ <?= tr('fc_modal_settings_title') ?></h3>
|
||||||
|
<button type="button" class="pf-modal-close" onclick="closeCalendarSettings()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 4px; background: var(--bg-page); padding: 4px; border-radius: 8px; margin: 1rem;">
|
||||||
|
<button type="button" id="tab-btn-foyer" class="bs-tab-btn active" style="flex: 1; padding: 8px 12px; font-weight: 600;" onclick="switchCalendarTab('foyer')">
|
||||||
|
🏡 <?= tr('fc_tab_foyer') ?>
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-btn-membres" class="bs-tab-btn" style="flex: 1; padding: 8px 12px; font-weight: 600;" onclick="switchCalendarTab('membres')">
|
||||||
|
👥 <?= tr('fc_tab_members') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-modal-body" style="max-height: 65vh; overflow-y: auto; padding-top: 0; padding-bottom: 1rem;">
|
||||||
|
|
||||||
|
<div id="cal-pane-foyer" class="cal-settings-pane">
|
||||||
|
|
||||||
|
<form id="formCalFoyer" onsubmit="submitCalFoyer(event)">
|
||||||
|
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label"><?= tr('fc_zone_label') ?></label>
|
||||||
|
<select id="setZoneScolaire" name="zone_scolaire" class="pf-input" required style="width: 100%;">
|
||||||
|
<option value="A"><?= tr('fc_zone_a') ?></option>
|
||||||
|
<option value="B"><?= tr('fc_zone_b') ?></option>
|
||||||
|
<option value="C"><?= tr('fc_zone_c') ?></option>
|
||||||
|
<option value="Autre"><?= tr('fc_zone_other') ?></option>
|
||||||
|
</select>
|
||||||
|
<small class="pf-muted-note"><?= tr('fc_zone_desc') ?></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-form-group" style="margin-top: 1.5rem;">
|
||||||
|
<label class="pf-label"><?= tr('fc_care_modes_label') ?></label>
|
||||||
|
|
||||||
|
<?php if (!empty($kids)): ?>
|
||||||
|
<div id="careModesContainer" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; min-height: 38px; padding: 8px; background: var(--bg-page); border-radius: var(--radius); border: 1px solid var(--border-light);"></div>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<input type="text" id="inputNewCareMode" class="pf-input" placeholder="<?= tr('fc_add_mode_placeholder') ?>" style="flex: 1;">
|
||||||
|
<button type="button" class="pf-btn btn-secondary" style="padding: 0 16px; border-radius: 6px;" onclick="addCareModeTag()">+</button>
|
||||||
|
</div>
|
||||||
|
<small class="pf-muted-note"><?= tr('fc_care_modes_desc') ?></small>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="background: var(--bg-subtle); padding: 12px; border-radius: 8px; border: 1px dashed var(--border-main); text-align: center;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: var(--text-muted); font-size: 0.9rem;">
|
||||||
|
<?= tr('fc_empty_kids_msg') ?>
|
||||||
|
</p>
|
||||||
|
<a href="/settings.php" class="pf-btn btn-secondary" style="text-decoration: none; display: inline-block; font-size: 0.85rem;">
|
||||||
|
⚙️ <?= tr('fc_link_settings') ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($kids)): ?>
|
||||||
|
<div style="margin-top: 1rem; display: flex; justify-content: flex-end;">
|
||||||
|
<button type="submit" class="pf-btn pf-btn-primary"><?= tr('save') ?></button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="pf-form-group" style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border-light);">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: var(--text-main);">🏖️ <?= tr('fc_leave_catalog_title') ?></h4>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 15px;">
|
||||||
|
<?= tr('fc_leave_catalog_desc') ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="leaveTypesContainer" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px;"></div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-subtle); padding: 12px; border-radius: 8px; border: 1px solid var(--border-light);">
|
||||||
|
<h5 id="leaveTypeFormTitle" style="margin: 0 0 10px 0;">+ <?= tr('fc_add_leave_type') ?></h5>
|
||||||
|
|
||||||
|
<form id="formLeaveType" style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
|
<input type="hidden" id="lt-mode" value="add">
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<div style="flex: 0 0 80px;">
|
||||||
|
<label class="pf-label"><?= tr('fc_col_code') ?> *</label>
|
||||||
|
<input type="text" id="lt-code" class="pf-input" maxlength="4" style="text-transform: uppercase;" required>
|
||||||
|
<small class="pf-muted-note" id="lt-code-note"><?= tr('fc_code_irreversible') ?></small>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 150px;">
|
||||||
|
<label class="pf-label"><?= tr('fc_col_label') ?> *</label>
|
||||||
|
<input type="text" id="lt-label" class="pf-input" placeholder="<?= tr('fc_leave_label_ph') ?>" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 5px;">
|
||||||
|
<button type="button" class="pf-btn btn-secondary" onclick="resetLeaveTypeForm()"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="button" class="pf-btn pf-btn-primary" onclick="saveLeaveType()"><?= tr('btn_save') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cal-pane-membres" class="cal-settings-pane" style="display: none;">
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.2rem;">
|
||||||
|
<label class="pf-label"><?= tr('fc_select_member') ?></label>
|
||||||
|
<select id="selectCalMember" class="pf-input" style="width: 100%; font-weight: 600;" onchange="loadMemberConfigView()">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="memberConfigZone" style="background: var(--bg-page); padding: 14px; border-radius: 8px; border: 1px solid var(--border-light); min-height: 150px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
<div class="fc-month-calendar-wrapper">
|
||||||
|
<div class="fc-month-header">
|
||||||
|
<div class="fc-month-nav-row">
|
||||||
|
<button id="fc-prev-month" class="fc-nav-button">‹</button>
|
||||||
|
<div id="fc-smart-date-selector" style="display:flex; align-items:center; justify-content:center; flex-grow:1; gap:6px;">
|
||||||
|
<select id="fc-select-month" class="fc-smart-select"></select>
|
||||||
|
<select id="fc-select-year" class="fc-smart-select"></select>
|
||||||
|
<span id="fc-multi-month-suffix" style="display:none; font-size:1.3rem; font-weight:800; color:#0f172a; margin-left:4px;"></span>
|
||||||
|
</div>
|
||||||
|
<button id="fc-next-month" class="fc-nav-button">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fc-view-controls">
|
||||||
|
<button id="fc-today-btn" class="fc-today-button" title="<?= tr('fc_today_title') ?>"><?= tr('fc_today_short') ?></button>
|
||||||
|
<div class="fc-view-divider"></div>
|
||||||
|
<button class="fc-view-button fc-view-button--active" data-view="1month"><?= tr('fc_view_1m') ?></button>
|
||||||
|
<button class="fc-view-button" data-view="2months"><?= tr('fc_view_2m') ?></button>
|
||||||
|
<button class="fc-view-button" data-view="3months"><?= tr('fc_view_3m') ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fc-calendar-container">
|
||||||
|
<div id="fc-month-calendar" class="fc-month-calendar"></div>
|
||||||
|
<div id="fc-month-selectionMenu" class="fc-selection-menu" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===================================================================== -->
|
<div id="fc-month-balances" class="fc-month-balances"></div>
|
||||||
<!-- PLANNING PRINCIPAL -->
|
</div>
|
||||||
<!-- ===================================================================== -->
|
|
||||||
<section class="pf-section">
|
|
||||||
<h2>Planning par semaine</h2>
|
|
||||||
<div class="pf-table-wrapper">
|
|
||||||
<table id="planningTable" class="pf-table pf-table--compact">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Mois</th>
|
|
||||||
<th>Semaine</th>
|
|
||||||
<th>Lundi</th>
|
|
||||||
<th>Mardi</th>
|
|
||||||
<th>Mercredi</th>
|
|
||||||
<th>Jeudi</th>
|
|
||||||
<th>Vendredi</th>
|
|
||||||
<th># Off Carole</th>
|
|
||||||
<th># Extra off Carole</th>
|
|
||||||
<th>#Centre</th>
|
|
||||||
<th>#Avis</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="planningBody">
|
|
||||||
<!-- Le contenu de ce tableau est entièrement généré par family-calendar.js -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Le menu contextuel est caché par défaut et son contenu est généré par JS -->
|
<section class="pf-section" style="margin-top: 1.5rem;">
|
||||||
<div id="selectionMenu" class="fc-selection-menu"></div>
|
<div class="fc-week-header">
|
||||||
</div>
|
<h2><?= tr('fc_weekly_planning') ?></h2>
|
||||||
</section>
|
<div class="fc-week-nav-controls">
|
||||||
|
<button id="fc-prev-school-year" class="fc-nav-button">‹</button>
|
||||||
|
<span id="fc-current-school-year-label"></span>
|
||||||
|
<button id="fc-next-school-year" class="fc-nav-button">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="planningTable-wrapper">
|
||||||
|
<table id="planningTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_month') ?></th>
|
||||||
|
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_week_short') ?></th>
|
||||||
|
<th rowspan="3" class="col-day"><?= tr('day_mon') ?></th>
|
||||||
|
<th rowspan="3" class="col-day"><?= tr('day_tue') ?></th>
|
||||||
|
<th rowspan="3" class="col-day"><?= tr('day_wed') ?></th>
|
||||||
|
<th rowspan="3" class="col-day"><?= tr('day_thu') ?></th>
|
||||||
|
<th rowspan="3" class="col-day"><?= tr('day_fri') ?></th>
|
||||||
|
|
||||||
<!-- ===================================================================== -->
|
<?php foreach ($activeCareModes as $mode): ?>
|
||||||
<!-- CHARGEMENT DU SCRIPT JAVASCRIPT PRINCIPAL -->
|
<th rowspan="3" class="col-total rotated-text"><span><?= htmlspecialchars($mode) ?></span></th>
|
||||||
<!-- ===================================================================== -->
|
<?php endforeach; ?>
|
||||||
<script src="/assets/js/family-calendar.js"></script>
|
|
||||||
|
|
||||||
|
<?php foreach ($kids as $kid): ?>
|
||||||
|
<th rowspan="3" class="col-total rotated-text"><span>Maladie <?= htmlspecialchars($kid['name']) ?></span></th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
<?php
|
<?php foreach ($parents as $index => $parent):
|
||||||
// Inclusion du pied de page
|
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia'; // Couleurs alternées
|
||||||
require __DIR__ . '/footer.php';
|
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
|
||||||
?>
|
$colspan = count($pLeaves) * 2;
|
||||||
|
?>
|
||||||
|
<?php if ($colspan > 0): ?>
|
||||||
|
<th colspan="<?= $colspan ?>" class="<?= $parentClass ?> header-group"><?= htmlspecialchars(strtoupper($parent['name'])) ?></th>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<?php foreach ($parents as $index => $parent):
|
||||||
|
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
|
||||||
|
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
|
||||||
|
?>
|
||||||
|
<?php foreach ($pLeaves as $type): ?>
|
||||||
|
<th colspan="2" class="<?= $parentClass ?>-sub"><?= htmlspecialchars($type) ?></th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<?php foreach ($parents as $index => $parent):
|
||||||
|
$parentClass = ($index % 2 === 0) ? 'col-alex' : 'col-laia';
|
||||||
|
$pLeaves = $leaveMatrix[$parent['id']] ?? [];
|
||||||
|
?>
|
||||||
|
<?php foreach ($pLeaves as $type): ?>
|
||||||
|
<th class="<?= $parentClass ?>-sub <?= $parentClass ?>-av"><?= tr('fc_col_av') ?></th>
|
||||||
|
<th class="<?= $parentClass ?>-sub <?= $parentClass ?>-use"><?= tr('fc_col_use') ?></th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="planningBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="selectionMenu" class="fc-selection-menu" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pf-section pf-section--bottom-panels" style="margin-top: 1.5rem;">
|
||||||
|
<div class="pf-card">
|
||||||
|
<h2 class="pf-card-title"><?= tr('fc_legend_title') ?></h2>
|
||||||
|
<div class="pf-card-body">
|
||||||
|
<div class="pf-legend-grid">
|
||||||
|
<!-- Fériés & Vacances (Fixes) -->
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-school-holiday"></div><span><?= tr('leg_school_holidays') ?></span></div>
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-public-holiday"></div><span><?= tr('leg_public_holiday') ?></span></div>
|
||||||
|
|
||||||
|
<!-- Intervenants (Dynamique) -->
|
||||||
|
<?php foreach ($helpers as $helper): ?>
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color" style="background: var(--warning);"></div><span>Off <?= htmlspecialchars($helper['name']) ?></span></div>
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color" style="background: var(--danger);"></div><span>Extra <?= htmlspecialchars($helper['name']) ?></span></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<!-- Modes de garde (Dynamique avec icônes) -->
|
||||||
|
<?php foreach ($activeCareModes as $index => $mode):
|
||||||
|
$modeLower = strtolower($mode);
|
||||||
|
if ($modeLower === 'avis'): ?>
|
||||||
|
<div class="pf-legend-item">
|
||||||
|
<img src="/modules/family-calendar/assets/img/avis.svg" style="width:16px;height:16px;object-fit:contain;margin-right:6px;" alt="Avis">
|
||||||
|
<span><?= htmlspecialchars($mode) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($modeLower === 'centre'): ?>
|
||||||
|
<div class="pf-legend-item">
|
||||||
|
<span style="font-size:1.1rem; line-height:1; margin-right:6px;">🏫</span>
|
||||||
|
<span><?= htmlspecialchars($mode) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="pf-legend-item">
|
||||||
|
<div class="pf-legend-color" style="background: var(--primary);"></div>
|
||||||
|
<span><?= htmlspecialchars($mode) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif;
|
||||||
|
endforeach; ?>
|
||||||
|
|
||||||
|
<!-- Enfants Malades (Dynamique avec couleur BDD) -->
|
||||||
|
<?php foreach ($kids as $kid):
|
||||||
|
// Fallback aligné exactement sur celui du JS (#e11d48)
|
||||||
|
$color = !empty($kid['color']) ? $kid['color'] : '#e11d48';
|
||||||
|
?>
|
||||||
|
<div class="pf-legend-item">
|
||||||
|
<div class="pf-legend-color" style="background: <?= htmlspecialchars($color) ?>; opacity: 0.8;"></div>
|
||||||
|
<span>Maladie <?= htmlspecialchars($kid['name']) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.FAMILY_CONFIG = {
|
||||||
|
parents: <?= json_encode($parents) ?>,
|
||||||
|
kids: <?= json_encode($kids) ?>,
|
||||||
|
activeCareModes: <?= json_encode($activeCareModes) ?>,
|
||||||
|
i18n: {
|
||||||
|
settings_updated: <?= json_encode(tr('fc_settings_updated')) ?>,
|
||||||
|
empty_matrix: "Aucun compteur de congés défini pour le moment.",
|
||||||
|
btn_delete: <?= json_encode(tr('btn_delete') ?? 'Supprimer') ?>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="/modules/family-calendar/family-calendar.js?v=<?= time() ?>"></script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
|
After Width: | Height: | Size: 55 KiB |
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
<footer class="pf-footer">
|
<footer class="pf-footer">
|
||||||
<div class="pf-container">
|
<div class="pf-container">
|
||||||
<small>© <?php echo date('Y'); ?> PachaFamily</small>
|
<small>© <?php echo date('Y'); ?> HouseHub</small>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div> <!-- FERMETURE pf-page -->
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = tr('garage_page_title');
|
||||||
|
$activePage = "garage";
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/garage/assets/garage.css">
|
||||||
|
<div id="toasts" class="toast-container"></div>
|
||||||
|
|
||||||
|
<!-- Sous-navigation -->
|
||||||
|
<nav class="garage-subnav">
|
||||||
|
<button class="nav-link active" data-page="dashboard">📊 Dashboard</button>
|
||||||
|
<button class="nav-link" data-page="vehicles">🚗 Véhicules</button>
|
||||||
|
<button class="nav-link" data-page="maintenances-all">🔧 Entretiens</button>
|
||||||
|
<button class="nav-link" data-page="parts">🔩 Pièces</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ── DASHBOARD ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="page-dashboard" class="page active">
|
||||||
|
<div class="container">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-pill blue"><div class="label">Véhicules</div><div class="value" id="stat-vehicles">--</div></div>
|
||||||
|
<div class="stat-pill green"><div class="label">Entretiens</div><div class="value" id="stat-maintenances">--</div></div>
|
||||||
|
<div class="stat-pill amber"><div class="label">Pièces</div><div class="value" id="stat-parts">--</div></div>
|
||||||
|
<div class="stat-pill red"><div class="label">Coût total</div><div class="value" id="stat-cost">--</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="garage-dash-layout">
|
||||||
|
<div class="garage-dash-main">
|
||||||
|
<div class="card-header garage-card-header" style="margin-bottom:.75rem">
|
||||||
|
<h2 style="font-size:1rem;font-weight:600">Mes véhicules</h2>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openAddVehicle()">+ Ajouter</button>
|
||||||
|
</div>
|
||||||
|
<div id="dashboard-vehicles" class="vehicles-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card garage-dash-aside">
|
||||||
|
<div class="card-header"><div class="card-title">🔔 Rappels & prochains entretiens</div></div>
|
||||||
|
<div id="reminders-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── VÉHICULES ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="page-vehicles" class="page">
|
||||||
|
<div class="container">
|
||||||
|
<div class="card-header garage-card-header" style="margin-bottom:1rem">
|
||||||
|
<h1 style="font-size:1.2rem;font-weight:700">🚗 Mes véhicules</h1>
|
||||||
|
<button class="btn btn-primary" onclick="openAddVehicle()">+ Ajouter un véhicule</button>
|
||||||
|
</div>
|
||||||
|
<div id="vehicles-grid" class="vehicles-grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DÉTAIL VÉHICULE ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="page-vehicle" class="page">
|
||||||
|
<div class="container">
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="navigate('vehicles')">← Retour</button>
|
||||||
|
</div>
|
||||||
|
<div id="vehicle-header"></div>
|
||||||
|
<div class="tabs garage-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="maintenances" onclick="switchTab('maintenances')">🔧 Entretiens <span id="maintenance-total" style="color:var(--muted);font-size:.78rem"></span></button>
|
||||||
|
<button class="tab-btn" data-tab="parts" onclick="switchTab('parts')">🔩 Pièces <span id="parts-total-vehicle" style="color:var(--muted);font-size:.78rem"></span></button>
|
||||||
|
<button class="tab-btn" data-tab="documents" onclick="switchTab('documents')">📎 Documents <span id="vehicle-docs-tab-count" style="color:var(--muted);font-size:.78rem"></span></button>
|
||||||
|
</div>
|
||||||
|
<div id="tab-maintenances" class="tab-pane active">
|
||||||
|
<div class="garage-tab-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openAddMaintenance()">+ Ajouter un entretien</button>
|
||||||
|
</div>
|
||||||
|
<div id="maintenance-list"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tab-parts" class="tab-pane">
|
||||||
|
<div class="garage-tab-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openAddPart()">+ Ajouter une pièce</button>
|
||||||
|
</div>
|
||||||
|
<div id="parts-list-vehicle"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tab-documents" class="tab-pane">
|
||||||
|
<div class="garage-doc-upload-bar">
|
||||||
|
<div class="form-group" style="flex:1;min-width:0;margin:0">
|
||||||
|
<label class="form-label" for="vehicle-doc-label">Libellé (optionnel)</label>
|
||||||
|
<input type="text" class="form-control" id="vehicle-doc-label" placeholder="Carte grise, assurance…">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;flex:1;min-width:140px">
|
||||||
|
<label class="form-label" for="vehicle-doc-file">Fichier</label>
|
||||||
|
<input type="file" class="form-control" id="vehicle-doc-file" accept=".pdf,.png,.jpg,.jpeg,.webp,.doc,.docx,.xls,.xlsx,.txt">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm garage-doc-upload-btn" onclick="uploadVehicleDocument()">Envoyer</button>
|
||||||
|
</div>
|
||||||
|
<p class="garage-doc-hint">PDF, images, Office… · max 12 Mo</p>
|
||||||
|
<div id="vehicle-documents-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DÉTAIL ENTRETIEN ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="page-maintenance" class="page">
|
||||||
|
<div class="container">
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="maintenance-back-btn" onclick="navigate('vehicle',{id:currentVehicleId})">← Retour au véhicule</button>
|
||||||
|
</div>
|
||||||
|
<div id="maintenance-detail-header"></div>
|
||||||
|
|
||||||
|
<div class="card garage-docs-card" style="margin-top:1rem">
|
||||||
|
<div class="card-header garage-card-header">
|
||||||
|
<span class="card-title">📎 Documents & factures (cette révision)</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 1.25rem 1rem">
|
||||||
|
<div class="garage-doc-upload-bar">
|
||||||
|
<div class="form-group" style="flex:1;min-width:0;margin:0">
|
||||||
|
<label class="form-label" for="maint-doc-label">Libellé (ex. facture garage)</label>
|
||||||
|
<input type="text" class="form-control" id="maint-doc-label" placeholder="Facture, devis…">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;flex:1;min-width:140px">
|
||||||
|
<label class="form-label" for="maint-doc-file">Fichier</label>
|
||||||
|
<input type="file" class="form-control" id="maint-doc-file" accept=".pdf,.png,.jpg,.jpeg,.webp,.doc,.docx,.xls,.xlsx,.txt">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm garage-doc-upload-btn" onclick="uploadMaintenanceDocument()">Envoyer</button>
|
||||||
|
</div>
|
||||||
|
<p class="garage-doc-hint">PDF, images… · max 12 Mo</p>
|
||||||
|
<div id="maintenance-documents-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:0;margin-top:1.5rem">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">🔩 Pièces utilisées <span id="maint-parts-count" class="badge badge-gray" style="margin-left:.5rem"></span></span>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="toggleInlinePartForm()">+ Ajouter une pièce</button>
|
||||||
|
</div>
|
||||||
|
<!-- Formulaire inline ajout pièce -->
|
||||||
|
<div id="inline-part-form" style="display:none;background:#f8fafc;border-bottom:1px solid #e2e8f0;padding:1.25rem 1.5rem">
|
||||||
|
<p style="font-size:.8rem;font-weight:600;color:#64748b;margin-bottom:.75rem;text-transform:uppercase;letter-spacing:.05em">🔄 Réutiliser une pièce connue</p>
|
||||||
|
<div id="known-parts-list"></div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
|
<div class="form-group"><label class="form-label">Nom *</label><input class="form-control" id="ipart-name" placeholder="Filtre à huile"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Référence</label><input class="form-control" id="ipart-reference" placeholder="Ex: 06A115561B" style="font-family:monospace"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||||
|
<div class="form-group"><label class="form-label">Marque</label><input class="form-control" id="ipart-brand" placeholder="Bosch, NGK..."></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Prix unitaire (€)</label>
|
||||||
|
<input class="form-control" id="ipart-price" type="number" step="0.01" value="0" oninput="updateIPriceTTC()">
|
||||||
|
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.4rem;font-size:.8rem;color:#64748b;cursor:pointer">
|
||||||
|
<input type="checkbox" id="ipart-prix-ht" onchange="updateIPriceTTC()" style="width:14px;height:14px"> Prix HT (+20% TVA)
|
||||||
|
</label>
|
||||||
|
<p id="ipart-ttc-preview" style="display:none;font-size:.8rem;color:#2563eb;margin-top:.2rem;font-family:monospace"></p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label class="form-label">Quantité</label><input class="form-control" id="ipart-quantity" type="number" value="1" min="1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label class="form-label">Photo (optionnel)</label><input class="form-control" id="ipart-photo" type="file" accept="image/*"></div>
|
||||||
|
<div style="display:flex;gap:.5rem;margin-top:.5rem">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="saveInlinePart()">+ Ajouter</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="toggleInlinePartForm()">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="maintenance-parts-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TOUS LES ENTRETIENS ─────────────────────────────────────────────────── -->
|
||||||
|
<div id="page-maintenances-all" class="page">
|
||||||
|
<div class="container">
|
||||||
|
<div class="card-header garage-card-header" style="margin-bottom:1rem">
|
||||||
|
<div>
|
||||||
|
<h1 style="font-size:1.2rem;font-weight:700">🔧 Tous les entretiens</h1>
|
||||||
|
<p style="color:var(--muted);font-size:.85rem"><span id="all-maint-count">0</span> entretiens · Total : <span id="all-maint-total">--</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="all-maintenances-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TOUTES LES PIÈCES ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="page-parts" class="page">
|
||||||
|
<div class="container">
|
||||||
|
<div class="card-header garage-card-header" style="margin-bottom:1rem">
|
||||||
|
<div>
|
||||||
|
<h1 style="font-size:1.2rem;font-weight:700">🔩 Toutes les pièces</h1>
|
||||||
|
<p style="color:var(--muted);font-size:.85rem"><span id="all-parts-count">0</span> pièces · Total : <span id="all-parts-total">--</span></p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="openAddPart()">+ Ajouter une pièce</button>
|
||||||
|
</div>
|
||||||
|
<div id="all-parts-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══ MODALS ════════════════════════════════════════════════════════════════ -->
|
||||||
|
|
||||||
|
<!-- MODAL: VEHICLE -->
|
||||||
|
<div id="modal-vehicle" class="modal-backdrop">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modal-vehicle-title">Ajouter un véhicule</div>
|
||||||
|
<button class="modal-close" onclick="closeModal('modal-vehicle')">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="form-vehicle" onsubmit="return false">
|
||||||
|
<input type="hidden" id="form-vehicle-id" name="id">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Nom *</label><input class="form-control" id="vehicle-name" name="name" required placeholder="Ma Clio"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Marque *</label><input class="form-control" id="vehicle-brand" name="brand" required placeholder="Renault"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Modèle *</label><input class="form-control" id="vehicle-model" name="model" required placeholder="Clio 4"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Année</label><input class="form-control" id="vehicle-year" name="year" type="number" placeholder="2019"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Immatriculation</label><input class="form-control" id="vehicle-license-plate" name="license_plate" placeholder="AB-123-CD"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Carburant</label>
|
||||||
|
<select class="form-control" id="vehicle-fuel-type" name="fuel_type">
|
||||||
|
<option>Essence</option><option>Diesel</option><option>Hybride</option><option>Electrique</option><option>GPL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Kilométrage</label><input class="form-control" id="vehicle-current-km" name="current_km" type="number" placeholder="85000"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Conso. moy. (L/100km)</label>
|
||||||
|
<input class="form-control" id="vehicle-consumption" name="consumption" type="number" step="0.1" min="0" placeholder="ex: 6.5">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Date d'achat</label><input class="form-control" id="vehicle-purchase-date" name="purchase_date" type="date"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Prix d'achat (€)</label><input class="form-control" id="vehicle-purchase-price" name="purchase_price" type="number" step="0.01"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">VIN</label><input class="form-control" id="vehicle-vin" name="vin" placeholder="VF1..."></div>
|
||||||
|
<div class="form-group"><label class="form-label">Couleur</label><input class="form-control" id="vehicle-color" name="color" placeholder="Blanc nacré"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Photo</label>
|
||||||
|
<input class="form-control" id="vehicle-photo" name="photo" type="file" accept="image/*" onchange="previewPhoto('vehicle-photo','vehicle-photo-preview')">
|
||||||
|
<img id="vehicle-photo-preview" style="display:none;max-height:120px;margin-top:.5rem;border-radius:6px" alt="">
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label class="form-label">Notes</label><textarea class="form-control" id="vehicle-notes" name="notes" rows="2"></textarea></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('modal-vehicle')">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveVehicle()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: MAINTENANCE -->
|
||||||
|
<div id="modal-maintenance" class="modal-backdrop">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modal-maintenance-title">Ajouter un entretien</div>
|
||||||
|
<button class="modal-close" onclick="closeModal('modal-maintenance')">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="form-maintenance" onsubmit="return false">
|
||||||
|
<input type="hidden" id="form-maintenance-id" name="id">
|
||||||
|
<input type="hidden" id="maintenance-vehicle-id" name="vehicle_id">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Type *</label>
|
||||||
|
<select class="form-control" id="maintenance-type" name="type">
|
||||||
|
<option>Vidange</option><option>Révision</option><option>Freins</option><option>Pneus</option>
|
||||||
|
<option>Distribution</option><option>Filtres</option><option>Batterie</option><option>Climatisation</option>
|
||||||
|
<option>Carrosserie</option><option>Diagnostic</option><option>Contrôle technique</option><option>Autre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label class="form-label">Date *</label><input class="form-control" id="maintenance-date" name="date" type="date" required></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Kilométrage</label><input class="form-control" id="maintenance-km" name="km" type="number" placeholder="85000"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Coût main d'œuvre (€)</label><input class="form-control" id="maintenance-cost" name="cost" type="number" step="0.01" placeholder="0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label class="form-label">Description</label><textarea class="form-control" id="maintenance-description" name="description" rows="2"></textarea></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Mécanicien</label><input class="form-control" id="maintenance-mechanic" name="mechanic"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Garage / Atelier</label><input class="form-control" id="maintenance-garage" name="garage_name"></div>
|
||||||
|
</div>
|
||||||
|
<div style="background:rgba(37,99,235,.05);border:1px solid rgba(37,99,235,.1);border-radius:8px;padding:1rem;margin-top:.5rem">
|
||||||
|
<div style="font-size:.82rem;color:var(--muted);margin-bottom:.75rem;font-weight:600">🔔 Prochain entretien</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Date prévue</label><input class="form-control" id="maintenance-next-date" name="next_date" type="date"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Kilométrage prévu</label><input class="form-control" id="maintenance-next-km" name="next_km" type="number" placeholder="95000"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:1rem"><label class="form-label">Notes</label><textarea class="form-control" id="maintenance-notes" name="notes" rows="2"></textarea></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('modal-maintenance')">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveMaintenance()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: PART -->
|
||||||
|
<div id="modal-part" class="modal-backdrop">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modal-part-title">Ajouter une pièce</div>
|
||||||
|
<button class="modal-close" onclick="closeModal('modal-part')">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="form-part" onsubmit="return false">
|
||||||
|
<input type="hidden" id="form-part-id" name="id">
|
||||||
|
<input type="hidden" id="part-vehicle-id" name="vehicle_id">
|
||||||
|
<input type="hidden" id="part-maintenance-id" name="maintenance_id">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Nom *</label><input class="form-control" id="part-name" name="name" required placeholder="Filtre à huile"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Catégorie</label>
|
||||||
|
<select class="form-control" id="part-category" name="category">
|
||||||
|
<option>Moteur</option><option>Freinage</option><option>Suspension</option><option>Transmission</option>
|
||||||
|
<option>Carrosserie</option><option>Electricite</option><option>Eclairage</option><option>Filtration</option>
|
||||||
|
<option>Refroidissement</option><option>Echappement</option><option>Pneumatiques</option><option>Autre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Marque</label><input class="form-control" id="part-brand" name="brand" placeholder="Bosch, NGK..."></div>
|
||||||
|
<div class="form-group"><label class="form-label">Référence</label><input class="form-control" id="part-reference" name="reference"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Prix unitaire (€)</label>
|
||||||
|
<input class="form-control" id="part-price" name="price" type="number" step="0.01" placeholder="12.50" oninput="updatePartPriceTTC()">
|
||||||
|
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.4rem;font-size:.8rem;color:#64748b;cursor:pointer">
|
||||||
|
<input type="checkbox" id="part-prix-ht" onchange="updatePartPriceTTC()" style="width:14px;height:14px"> Prix HT (+20% TVA)
|
||||||
|
</label>
|
||||||
|
<p id="part-ttc-preview" style="display:none;font-size:.8rem;color:#2563eb;margin-top:.2rem;font-family:monospace"></p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label class="form-label">Quantité</label><input class="form-control" id="part-quantity" name="quantity" type="number" value="1" min="1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Photo</label>
|
||||||
|
<input class="form-control" id="part-photo" name="photo" type="file" accept="image/*" onchange="previewPhoto('part-photo','part-photo-preview')">
|
||||||
|
<img id="part-photo-preview" style="display:none;max-height:100px;margin-top:.5rem;border-radius:6px" alt="">
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label class="form-label">Notes</label><textarea class="form-control" id="part-notes" name="notes" rows="2"></textarea></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('modal-part')">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="savePart()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: UPLOAD PHOTO VÉHICULE -->
|
||||||
|
<div id="modal-upload-photo" class="modal-backdrop">
|
||||||
|
<div class="modal" style="max-width:400px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">📷 Changer la photo</div>
|
||||||
|
<button class="modal-close" onclick="closeModal('modal-upload-photo')">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="upload-vehicle-id">
|
||||||
|
<div class="form-group"><label class="form-label">Nouvelle photo</label><input class="form-control" id="upload-photo-file" type="file" accept="image/*"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('modal-upload-photo')">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="doUploadPhoto()">Mettre à jour</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/modules/garage/assets/garage.js"></script>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/includes/meta_db.php';
|
||||||
|
|
||||||
|
$db_host = getenv('DB_HOST') ?: '127.0.0.1';
|
||||||
|
$db_user = getenv('DB_USER') ?: 'househub';
|
||||||
|
$db_pass = getenv('DB_PASS') ?: 'househub_dev';
|
||||||
|
|
||||||
|
echo "<h1>📅 Génération du squelette du calendrier (pf_calendar_weeks)</h1>";
|
||||||
|
|
||||||
|
$monthsFr = [1=>'Janvier', 2=>'Février', 3=>'Mars', 4=>'Avril', 5=>'Mai', 6=>'Juin', 7=>'Juillet', 8=>'Août', 9=>'Septembre', 10=>'Octobre', 11=>'Novembre', 12=>'Décembre'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $meta_pdo->query("SELECT db_name, name FROM families WHERE is_active = 1");
|
||||||
|
$families = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
foreach ($families as $family) {
|
||||||
|
$dbName = $family['db_name'];
|
||||||
|
echo "<h3>Famille : {$family['name']} ($dbName)</h3><ul>";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
|
||||||
|
// On utilise REPLACE INTO pour écraser proprement si certaines semaines existent déjà
|
||||||
|
$sql = "REPLACE INTO pf_calendar_weeks
|
||||||
|
(year, week_iso_year, week_iso_number, week_label, month, month_name, week_start_date, mon_date, tue_date, wed_date, thu_date, fri_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
$insertStmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
// On génère de l'été 2023 jusqu'à fin 2030 !
|
||||||
|
$startDate = new DateTime('2023-08-28'); // Un lundi
|
||||||
|
$endDate = new DateTime('2030-12-31');
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
while ($startDate <= $endDate) {
|
||||||
|
$mon = clone $startDate;
|
||||||
|
$tue = clone $startDate; $tue->modify('+1 day');
|
||||||
|
$wed = clone $startDate; $wed->modify('+2 days');
|
||||||
|
$thu = clone $startDate; $thu->modify('+3 days');
|
||||||
|
$fri = clone $startDate; $fri->modify('+4 days');
|
||||||
|
|
||||||
|
$year = (int)$mon->format('Y');
|
||||||
|
$month = (int)$mon->format('n');
|
||||||
|
$weekIsoYear = (int)$mon->format('o');
|
||||||
|
$weekIsoNumber = (int)$mon->format('W');
|
||||||
|
$weekLabel = "Semaine " . $weekIsoNumber;
|
||||||
|
$monthName = $monthsFr[$month];
|
||||||
|
|
||||||
|
$insertStmt->execute([
|
||||||
|
$year,
|
||||||
|
$weekIsoYear,
|
||||||
|
$weekIsoNumber,
|
||||||
|
$weekLabel,
|
||||||
|
$month,
|
||||||
|
$monthName,
|
||||||
|
$mon->format('Y-m-d'),
|
||||||
|
$mon->format('Y-m-d'),
|
||||||
|
$tue->format('Y-m-d'),
|
||||||
|
$wed->format('Y-m-d'),
|
||||||
|
$thu->format('Y-m-d'),
|
||||||
|
$fri->format('Y-m-d')
|
||||||
|
]);
|
||||||
|
|
||||||
|
$startDate->modify('+1 week');
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
echo "<li>✅ $count semaines générées (de 2023 à 2030) !</li>";
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
echo "<li>❌ Erreur : " . $e->getMessage() . "</li>";
|
||||||
|
}
|
||||||
|
echo "</ul>";
|
||||||
|
}
|
||||||
|
echo "<h2>🎉 Terminé ! Tu peux supprimer ce fichier et rafraîchir ton calendrier.</h2>";
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
die("Erreur fatale Meta DB : " . $e->getMessage());
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,974 @@
|
|||||||
|
<?php
|
||||||
|
// modules/gift-list/gift-list.php
|
||||||
|
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login('/login.php');
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) { session_start(); }
|
||||||
|
|
||||||
|
// --- 1. CONFIGURATION DE BASE ---
|
||||||
|
$year = (int)date('Y');
|
||||||
|
$pageTitle = tr('gift_page_title');
|
||||||
|
$activePage = "gift-list";
|
||||||
|
$mainClass = "pf-gift-list";
|
||||||
|
$pageCss = "/modules/gift-list/gift-list.css";
|
||||||
|
|
||||||
|
// --- 2. RÉCUPÉRATION DYNAMIQUE DES DONNÉES ---
|
||||||
|
// On inclut les enfants du foyer ET les proches enfants
|
||||||
|
$stmt = $pdo->query("SELECT name FROM pf_people WHERE role IN ('enfant', 'proche_enfant') ORDER BY name ASC");
|
||||||
|
$children = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: ['Aucun enfant configuré'];
|
||||||
|
|
||||||
|
// On inclut les adultes du foyer ET les proches adultes (On exclut nounou et les enfants)
|
||||||
|
$stmt = $pdo->query("SELECT name FROM pf_people WHERE role NOT IN ('enfant', 'proche_enfant', 'nounou') ORDER BY name ASC");
|
||||||
|
$allAdultsList = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: ['Aucun adulte configuré'];
|
||||||
|
|
||||||
|
// On récupère toutes les occasions (avec l'icône)
|
||||||
|
$stmt = $pdo->query("SELECT code, name, icon, month_date FROM pf_gift_occasions WHERE is_active = 1 ORDER BY sort_order ASC, month_date ASC, id ASC");
|
||||||
|
$activeOccasions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (empty($activeOccasions)) {
|
||||||
|
$activeOccasions = [['code' => 'DEMO', 'name' => 'Fête par défaut', 'icon' => '🎁', 'month_date' => null]];
|
||||||
|
}
|
||||||
|
$allowedOccasionCodes = array_column($activeOccasions, 'code');
|
||||||
|
|
||||||
|
// --- 3. CHARGEMENT DES CADEAUX ---
|
||||||
|
$inMarks = implode(',', array_fill(0, count($allowedOccasionCodes), '?'));
|
||||||
|
$sql = "SELECT * FROM pf_gifts WHERE year = ? AND occasion IN ($inMarks) ORDER BY occasion ASC, child_name ASC, adult_name ASC";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute(array_merge([$year], $allowedOccasionCodes));
|
||||||
|
$gifts = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
$adultsInView = [];
|
||||||
|
|
||||||
|
foreach ($gifts as $g) {
|
||||||
|
$data[$g['occasion']][$g['child_name']]['gifts'][] = $g;
|
||||||
|
$data[$g['occasion']][$g['child_name']]['totals'][$g['adult_name']] = ($data[$g['occasion']][$g['child_name']]['totals'][$g['adult_name']] ?? 0) + $g['amount'];
|
||||||
|
$adultsInView[$g['adult_name']] = true;
|
||||||
|
if (!empty($g['payer_name'])) {
|
||||||
|
$adultsInView[$g['payer_name']] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. TRICOUNT (Bilan et Remboursements) ---
|
||||||
|
$people = array_values(array_unique(array_merge($allAdultsList, array_keys($adultsInView))));
|
||||||
|
$people = array_filter($people);
|
||||||
|
sort($people);
|
||||||
|
|
||||||
|
$matrix = [];
|
||||||
|
foreach ($people as $p1) { foreach ($people as $p2) $matrix[$p1][$p2] = 0.0; }
|
||||||
|
|
||||||
|
foreach ($gifts as $g) {
|
||||||
|
$adult = $g['adult_name'];
|
||||||
|
$payer = $g['payer_name'] ?? $g['adult_name'];
|
||||||
|
$amt = (float)$g['amount'];
|
||||||
|
if ($amt > 0 && $adult && $payer && $adult !== $payer) {
|
||||||
|
$matrix[$adult][$payer] += $amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$settlements = [];
|
||||||
|
$countPeople = count($people);
|
||||||
|
for ($i = 0; $i < $countPeople; $i++) {
|
||||||
|
for ($j = $i + 1; $j < $countPeople; $j++) {
|
||||||
|
$a = $people[$i]; $b = $people[$j];
|
||||||
|
$net = $matrix[$a][$b] - $matrix[$b][$a];
|
||||||
|
if ($net > 0.01) { $settlements[] = ['from' => $a, 'to' => $b, 'amount' => $net]; }
|
||||||
|
elseif ($net < -0.01) { $settlements[] = ['from' => $b, 'to' => $a, 'amount' => -$net]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="pf-container cl-view-dynamic">
|
||||||
|
|
||||||
|
<div class="cl-titlebar">
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
<h1 style="margin: 0;">🎁 <?= sprintf(tr('gift_main_title'), $year) ?></h1>
|
||||||
|
<button id="btn-open-gift-settings" class="btn-settings-gear" title="<?= tr('gift_settings') ?>" style="font-size: 1.4rem; padding: 4px;">
|
||||||
|
⚙️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-filter-bar">
|
||||||
|
<span class="pf-filter-icon">🔍</span>
|
||||||
|
|
||||||
|
<div class="pf-multi-select" id="ms-child">
|
||||||
|
<div class="pf-ms-trigger" onclick="toggleMS('ms-child-list', this)">
|
||||||
|
👦 <span id="ms-child-label"><?= tr('gift_filter_all_children') ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="pf-ms-dropdown" id="ms-child-list">
|
||||||
|
<label class="pf-ms-option is-all">
|
||||||
|
<input type="checkbox" value="all" checked onchange="handleMSChange(this, 'child')">
|
||||||
|
<?= tr('gift_filter_all_children') ?>
|
||||||
|
</label>
|
||||||
|
<?php foreach($children as $c): ?>
|
||||||
|
<label class="pf-ms-option"><input type="checkbox" value="<?= htmlspecialchars($c) ?>" onchange="handleMSChange(this, 'child')"> <?= htmlspecialchars($c) ?></label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-multi-select" id="ms-adult">
|
||||||
|
<div class="pf-ms-trigger" onclick="toggleMS('ms-adult-list', this)">
|
||||||
|
👤 <span id="ms-adult-label"><?= tr('gift_filter_all_adults') ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="pf-ms-dropdown" id="ms-adult-list">
|
||||||
|
<label class="pf-ms-option is-all">
|
||||||
|
<input type="checkbox" value="all" checked onchange="handleMSChange(this, 'adult')">
|
||||||
|
<?= tr('gift_filter_all_adults') ?>
|
||||||
|
</label>
|
||||||
|
<?php foreach($allAdultsList as $a): ?>
|
||||||
|
<label class="pf-ms-option"><input type="checkbox" value="<?= htmlspecialchars($a) ?>" onchange="handleMSChange(this, 'adult')"> <?= htmlspecialchars($a) ?></label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="pf-section">
|
||||||
|
<?php foreach ($activeOccasions as $occ):
|
||||||
|
$occCode = $occ['code'];
|
||||||
|
// Affichage Brut de la BDD pour le nom de l'occasion
|
||||||
|
$occName = $occ['name'];
|
||||||
|
$occIcon = $occ['icon'] ?: '🎁';
|
||||||
|
?>
|
||||||
|
<div class="js-occ-section">
|
||||||
|
<h2 class="cl-occasion-title">
|
||||||
|
<span class="cl-occasion-icon"><?= htmlspecialchars($occIcon) ?></span>
|
||||||
|
<?= htmlspecialchars($occName) ?>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<?php foreach ($children as $child):
|
||||||
|
$childData = $data[$occCode][$child] ?? ['gifts' => [], 'totals' => []];
|
||||||
|
?>
|
||||||
|
<div class="pf-child-section js-child" data-name="<?= htmlspecialchars($child) ?>">
|
||||||
|
<div class="pf-child-header">
|
||||||
|
<h3>👦 <?= htmlspecialchars($child) ?></h3>
|
||||||
|
<button class="pf-btn pf-btn-small btn-add-gift"
|
||||||
|
data-child="<?= htmlspecialchars($child) ?>"
|
||||||
|
data-occ="<?= htmlspecialchars($occCode) ?>"
|
||||||
|
data-adults="<?= htmlspecialchars(json_encode(array_values($allAdultsList))) ?>">
|
||||||
|
+ <?= tr('gift_add_gift') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-child-totals-bar">
|
||||||
|
<?php foreach ($childData['totals'] as $adult => $tot): ?>
|
||||||
|
<span class="pf-summary-pill js-pill-adult" data-adult="<?= htmlspecialchars($adult) ?>">👤 <?= htmlspecialchars($adult) ?> : <strong><?= number_format($tot, 2, ',', '') ?> €</strong></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($childData['gifts'])): ?>
|
||||||
|
<p class="js-empty-state gift-empty-state"><?= tr('gift_empty_state_no_gifts') ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="js-empty-state gift-empty-state pf-hidden"><?= tr('gift_empty_state_no_filter') ?></p>
|
||||||
|
<div class="pf-gift-feed">
|
||||||
|
<?php foreach ($childData['gifts'] as $g): ?>
|
||||||
|
<div class="pf-gift-card-compact js-gift-card" data-adult="<?= htmlspecialchars($g['adult_name']) ?>">
|
||||||
|
<div>
|
||||||
|
<h4 class="pf-gift-title">
|
||||||
|
<?= htmlspecialchars($g['gift_description']) ?>
|
||||||
|
<?php if($g['product_link']): ?><a href="<?= htmlspecialchars($g['product_link']) ?>" target="_blank" class="pf-gift-link">🔗</a><?php endif; ?>
|
||||||
|
</h4>
|
||||||
|
<span class="pf-pill-adult">👤 <?= htmlspecialchars($g['adult_name']) ?></span>
|
||||||
|
<?php if($g['payer_name'] && $g['payer_name'] !== $g['adult_name']): ?>
|
||||||
|
<div class="pf-gift-payer"><?= sprintf(tr('gift_paid_by'), htmlspecialchars($g['payer_name'])) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="pf-gift-footer">
|
||||||
|
<span class="pf-gift-price"><?= number_format($g['amount'], 2, ',', ' ') ?> €</span>
|
||||||
|
<div class="pf-gift-actions">
|
||||||
|
<button class="btn-icon-action edit btn-edit-gift" data-gift="<?= htmlspecialchars(json_encode($g)) ?>">✏️</button>
|
||||||
|
<button class="btn-icon-action delete btn-delete-gift" data-id="<?= $g['id'] ?>">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pf-section pf-section--panel gift-tricount-section">
|
||||||
|
<h2 class="gift-tricount-title">⚖️ <?= tr('gift_liquidations') ?></h2>
|
||||||
|
|
||||||
|
<?php if (empty($settlements)): ?>
|
||||||
|
<p class="gift-tricount-success">✅ <?= tr('gift_no_debt') ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="pf-tricount-list">
|
||||||
|
<?php foreach ($settlements as $s): ?>
|
||||||
|
<li class="pf-tricount-item">
|
||||||
|
<span><strong><?= htmlspecialchars($s['from']) ?></strong> <?= tr('gift_owes') ?> <?= tr('gift_to') ?> <?= htmlspecialchars($s['to']) ?></span>
|
||||||
|
<strong class="gift-tricount-debt-amount"><?= number_format($s['amount'], 2, ',', ' ') ?> €</strong>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<details class="gift-matrix-details">
|
||||||
|
<summary class="gift-matrix-summary"><?= tr('gift_view_matrix') ?></summary>
|
||||||
|
<div class="gift-matrix-wrapper">
|
||||||
|
<table class="pf-table pf-table--compact cl-debt-matrix">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sticky-col bg-header"><?= tr('gift_debtor') ?> \ <?= tr('gift_creditor') ?></th>
|
||||||
|
<?php foreach ($people as $p): ?><th><?= htmlspecialchars($p) ?></th><?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($people as $debtor): ?>
|
||||||
|
<tr>
|
||||||
|
<th class="sticky-col bg-body"><?= htmlspecialchars($debtor) ?></th>
|
||||||
|
<?php foreach ($people as $creditor):
|
||||||
|
$val = $matrix[$debtor][$creditor] ?? 0;
|
||||||
|
$display = ($debtor === $creditor) || $val == 0 ? '—' : number_format($val, 2, ',', ' ') . ' €';
|
||||||
|
$cellClass = $val > 0 ? 'gift-cell-danger' : 'gift-cell-muted';
|
||||||
|
?>
|
||||||
|
<td class="<?= $cellClass ?>"><?= $display ?></td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1. Modale CRUD Cadeau -->
|
||||||
|
<div id="pf-gift-modal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content gift-modal-custom-content">
|
||||||
|
<div class="gift-modal-custom-header">
|
||||||
|
<h3 id="modalTitle" class="pf-modal-title gift-modal-custom-title">Cadeau</h3>
|
||||||
|
<button type="button" class="btn-modal-close gift-modal-close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/modules/gift-list/save-gift.php" id="giftForm">
|
||||||
|
<input type="hidden" name="year" value="<?= $year ?>">
|
||||||
|
<input type="hidden" name="child_name" id="modalChild">
|
||||||
|
<input type="hidden" name="occasion" id="modalOccasion">
|
||||||
|
<input type="hidden" name="action" id="modalAction">
|
||||||
|
<input type="hidden" name="gift_id" id="modalGiftId">
|
||||||
|
|
||||||
|
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_col_adult') ?></label><select name="adult_name" id="modalAdult" class="pf-input" required></select></div>
|
||||||
|
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_payer') ?></label><select name="payer_name" id="modalPayer" class="pf-input" required></select></div>
|
||||||
|
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_gift_name') ?></label><input type="text" name="gift_description" id="modalDesc" class="pf-input" required></div>
|
||||||
|
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_price') ?></label><input type="number" step="0.01" name="amount" id="modalAmount" class="pf-input"></div>
|
||||||
|
<div class="pf-form-group gift-form-group-spaced"><label class="pf-label"><?= tr('gift_modal_link') ?></label><input type="url" name="product_link" id="modalLink" class="pf-input"></div>
|
||||||
|
|
||||||
|
<div class="modal-footer gift-modal-custom-footer">
|
||||||
|
<button type="button" class="pf-btn btn-secondary btn-modal-close"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Modale Paramètres (Occasions & Proches) -->
|
||||||
|
<div id="modal-gift-settings" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="max-width: 600px; width: 90%;">
|
||||||
|
|
||||||
|
<div class="pf-modal-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h3 class="pf-modal-title" style="margin: 0;">⚙️ <?= tr('gift_settings') ?></h3>
|
||||||
|
<button type="button" class="pf-modal-close" style="background:none; border:none; font-size:1.5rem; cursor:pointer;" onclick="closeGiftSettings()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 4px; background: var(--bg-page); padding: 4px; border-radius: 8px; margin: 1rem 0;">
|
||||||
|
<button type="button" id="tab-btn-occasions" class="bs-tab-btn active" style="flex: 1; padding: 8px 12px; font-weight: 600; border-radius: 6px; border: none; cursor: pointer; transition: 0.2s;" onclick="switchGLTab('occasions')"><?= tr('gift_tab_occasions') ?></button>
|
||||||
|
<button type="button" id="tab-btn-proches" class="bs-tab-btn" style="flex: 1; padding: 8px 12px; font-weight: 600; border-radius: 6px; border: none; cursor: pointer; transition: 0.2s; background: transparent;" onclick="switchGLTab('proches')"><?= tr('gift_tab_proches') ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-modal-body" style="min-height: 450px; max-height: 60vh; overflow-y: auto; padding: 0 1rem 1rem 1rem; display: flex; flex-direction: column;">
|
||||||
|
|
||||||
|
<div id="gl-pane-occasions" class="gl-pane active" style="display: block; animation: fadeIn 0.3s ease;">
|
||||||
|
<div id="occasions-list" style="margin-bottom: 20px;">⏳ <?= tr('loading') ?></div>
|
||||||
|
|
||||||
|
<hr style="border:0; border-top: 1px solid var(--border-light); margin: 20px 0;">
|
||||||
|
|
||||||
|
<h5 style="margin: 0 0 15px 0; font-size: 1rem; color: var(--text-main);"><?= tr('gift_settings_add_occ') ?></h5>
|
||||||
|
<form id="form-add-occasion" style="display: flex; flex-direction: column; gap: 16px;">
|
||||||
|
<div>
|
||||||
|
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_settings_icon') ?></label>
|
||||||
|
<input type="hidden" name="icon" id="add-occ-icon" value="🎁">
|
||||||
|
<div class="emoji-picker" style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<div class="emoji-option selected" onclick="selectEmoji(this, 'add-occ-icon', '🎁')">🎁</div>
|
||||||
|
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎄')">🎄</div>
|
||||||
|
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎂')">🎂</div>
|
||||||
|
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎉')">🎉</div>
|
||||||
|
<div class="emoji-option" onclick="selectEmoji(this, 'add-occ-icon', '🎓')">🎓</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<div style="flex: 1; min-width: 150px;">
|
||||||
|
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_modal_gift_name') ?> *</label>
|
||||||
|
<input type="text" name="name" id="add-occ-name" class="pf-input" style="width: 100%;" required>
|
||||||
|
</div>
|
||||||
|
<div style="width: 110px;">
|
||||||
|
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_settings_date') ?></label>
|
||||||
|
<input type="text" name="month_date" id="add-occ-date" class="pf-input" style="width: 100%;" placeholder="MM-DD">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px;">
|
||||||
|
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('form-add-occasion').reset()"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn pf-btn-primary"><?= tr('btn_add') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gl-pane-proches" class="gl-pane" style="display: none; animation: fadeIn 0.3s ease;">
|
||||||
|
<div id="proches-list" style="margin-bottom: 20px;">⏳ <?= tr('loading') ?></div>
|
||||||
|
|
||||||
|
<hr style="border:0; border-top: 1px solid var(--border-light); margin: auto 0 20px 0;">
|
||||||
|
|
||||||
|
<h5 style="margin: 0 0 15px 0; font-size: 1rem; color: var(--text-main);"><?= tr('gift_settings_add_proche') ?></h5>
|
||||||
|
<form id="form-add-proche" style="display: flex; flex-direction: column; gap: 16px;">
|
||||||
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<div style="flex: 1; min-width: 150px;">
|
||||||
|
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_ph_proche_name') ?> *</label>
|
||||||
|
<input type="text" name="name" id="add-proche-name" class="pf-input" style="width: 100%;" required>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 150px;">
|
||||||
|
<label class="pf-label" style="display: block; margin-bottom: 6px;"><?= tr('gift_role_label') ?> *</label>
|
||||||
|
<select name="role" id="add-proche-role" class="pf-input" style="width: 100%;" required>
|
||||||
|
<option value="proche_adulte"><?= tr('gift_role_proche_adulte') ?></option>
|
||||||
|
<option value="proche_enfant"><?= tr('gift_role_proche_enfant') ?></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px;">
|
||||||
|
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('form-add-proche').reset()"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn pf-btn-primary"><?= tr('btn_add') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ==========================================
|
||||||
|
// 1. GESTION DU COMPOSANT MULTI-SELECT VANILLA
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function toggleMS(listId, triggerEl) {
|
||||||
|
document.querySelectorAll('.pf-ms-dropdown').forEach(el => { if (el.id !== listId) el.classList.remove('open'); });
|
||||||
|
document.querySelectorAll('.pf-ms-trigger').forEach(el => { if (el !== triggerEl) el.classList.remove('active'); });
|
||||||
|
const list = document.getElementById(listId);
|
||||||
|
if (list) list.classList.toggle('open');
|
||||||
|
if (triggerEl) triggerEl.classList.toggle('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!e.target.closest('.pf-multi-select')) {
|
||||||
|
document.querySelectorAll('.pf-ms-dropdown').forEach(el => el.classList.remove('open'));
|
||||||
|
document.querySelectorAll('.pf-ms-trigger').forEach(el => el.classList.remove('active'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleMSChange(cb, type) {
|
||||||
|
const container = document.getElementById('ms-' + type + '-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (cb.value === 'all' && cb.checked) {
|
||||||
|
container.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(i => i.checked = false);
|
||||||
|
} else if (cb.checked) {
|
||||||
|
const allCb = container.querySelector('input[value="all"]');
|
||||||
|
if (allCb) allCb.checked = false;
|
||||||
|
} else {
|
||||||
|
const checkedSpecifics = container.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])');
|
||||||
|
if (checkedSpecifics.length === 0) {
|
||||||
|
const allCb = container.querySelector('input[value="all"]');
|
||||||
|
if (allCb) allCb.checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedSpecifics = container.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])');
|
||||||
|
const labelEl = document.getElementById('ms-' + type + '-label');
|
||||||
|
|
||||||
|
if (labelEl) {
|
||||||
|
if (checkedSpecifics.length === 0) {
|
||||||
|
let defaultText = 'Tous';
|
||||||
|
if (window.I18N) {
|
||||||
|
if (type === 'child' && window.I18N['gift_filter_all_children']) defaultText = window.I18N['gift_filter_all_children'];
|
||||||
|
if (type === 'adult' && window.I18N['gift_filter_all_adults']) defaultText = window.I18N['gift_filter_all_adults'];
|
||||||
|
}
|
||||||
|
labelEl.innerText = defaultText;
|
||||||
|
} else if (checkedSpecifics.length === 1) {
|
||||||
|
labelEl.innerText = checkedSpecifics[0].value;
|
||||||
|
} else {
|
||||||
|
labelEl.innerText = checkedSpecifics.length + ' sélections';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyGiftFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGiftFilters() {
|
||||||
|
const cList = document.getElementById('ms-child-list');
|
||||||
|
const aList = document.getElementById('ms-adult-list');
|
||||||
|
if (!cList || !aList) return;
|
||||||
|
|
||||||
|
const allChildCb = cList.querySelector('input[value="all"]');
|
||||||
|
const cAll = allChildCb ? allChildCb.checked : true;
|
||||||
|
const cVals = Array.from(cList.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])')).map(i => i.value);
|
||||||
|
|
||||||
|
const allAdultCb = aList.querySelector('input[value="all"]');
|
||||||
|
const aAll = allAdultCb ? allAdultCb.checked : true;
|
||||||
|
const aVals = Array.from(aList.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])')).map(i => i.value);
|
||||||
|
|
||||||
|
document.querySelectorAll('.js-occ-section').forEach(occSec => {
|
||||||
|
let occHasVisible = false;
|
||||||
|
|
||||||
|
occSec.querySelectorAll('.js-child').forEach(childSec => {
|
||||||
|
const cName = childSec.getAttribute('data-name');
|
||||||
|
const matchChild = cAll || (cVals.indexOf(cName) !== -1);
|
||||||
|
let childHasVisibleCard = false;
|
||||||
|
|
||||||
|
if (matchChild) {
|
||||||
|
childSec.querySelectorAll('.js-gift-card').forEach(card => {
|
||||||
|
const aName = card.getAttribute('data-adult');
|
||||||
|
const matchAdult = aAll || (aVals.indexOf(aName) !== -1);
|
||||||
|
card.style.display = matchAdult ? 'flex' : 'none';
|
||||||
|
if (matchAdult) childHasVisibleCard = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
childSec.querySelectorAll('.js-pill-adult').forEach(pill => {
|
||||||
|
const aName = pill.getAttribute('data-adult');
|
||||||
|
const matchAdult = aAll || (aVals.indexOf(aName) !== -1);
|
||||||
|
pill.style.opacity = matchAdult ? '1' : '0.2';
|
||||||
|
});
|
||||||
|
|
||||||
|
const empty = childSec.querySelector('.js-empty-state');
|
||||||
|
if (empty) empty.style.display = childHasVisibleCard ? 'none' : 'block';
|
||||||
|
|
||||||
|
childSec.style.display = 'block';
|
||||||
|
occHasVisible = true;
|
||||||
|
} else {
|
||||||
|
childSec.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
occSec.style.display = occHasVisible ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. GESTION DE LA MODALE CADEAU (AJOUT/ÉDITION)
|
||||||
|
// ==========================================
|
||||||
|
const modal = document.getElementById('pf-gift-modal');
|
||||||
|
const adultSelect = document.getElementById('modalAdult');
|
||||||
|
const payerSelect = document.getElementById('modalPayer');
|
||||||
|
|
||||||
|
if (adultSelect && payerSelect) {
|
||||||
|
adultSelect.addEventListener('change', function() {
|
||||||
|
let exists = false;
|
||||||
|
for(let i = 0; i < payerSelect.options.length; i++) {
|
||||||
|
if(payerSelect.options[i].value === this.value) exists = true;
|
||||||
|
}
|
||||||
|
if (exists) payerSelect.value = this.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateSelects(adults) {
|
||||||
|
if (!adultSelect || !payerSelect) return;
|
||||||
|
adultSelect.innerHTML = '';
|
||||||
|
payerSelect.innerHTML = '';
|
||||||
|
adults.forEach(name => {
|
||||||
|
adultSelect.appendChild(new Option(name, name));
|
||||||
|
payerSelect.appendChild(new Option(name, name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.addEventListener('click', async function(e) {
|
||||||
|
if (e.target.closest('.btn-modal-close')) {
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('open');
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnAdd = e.target.closest('.btn-add-gift');
|
||||||
|
if (btnAdd) {
|
||||||
|
const childName = btnAdd.dataset.child;
|
||||||
|
const occCode = btnAdd.dataset.occ;
|
||||||
|
const adultsList = JSON.parse(btnAdd.dataset.adults || '[]');
|
||||||
|
|
||||||
|
document.getElementById('modalAction').value = 'create';
|
||||||
|
document.getElementById('modalGiftId').value = '';
|
||||||
|
document.getElementById('modalChild').value = childName;
|
||||||
|
document.getElementById('modalOccasion').value = occCode;
|
||||||
|
document.getElementById('modalDesc').value = '';
|
||||||
|
document.getElementById('modalAmount').value = '';
|
||||||
|
document.getElementById('modalLink').value = '';
|
||||||
|
|
||||||
|
populateSelects(adultsList);
|
||||||
|
|
||||||
|
if (adultSelect && payerSelect) {
|
||||||
|
if (adultsList.indexOf('Laia') !== -1) {
|
||||||
|
adultSelect.value = 'Laia'; payerSelect.value = 'Laia';
|
||||||
|
} else if (adultsList.length > 0) {
|
||||||
|
adultSelect.value = adultsList[0]; payerSelect.value = adultsList[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleStr = window.I18N && window.I18N['gift_modal_title_add'] ? window.I18N['gift_modal_title_add'] : 'Ajouter pour %s';
|
||||||
|
document.getElementById('modalTitle').textContent = titleStr.replace('%s', childName);
|
||||||
|
|
||||||
|
if (modal) { modal.classList.add('open'); document.body.classList.add('no-scroll'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnEdit = e.target.closest('.btn-edit-gift');
|
||||||
|
if (btnEdit) {
|
||||||
|
const data = JSON.parse(btnEdit.dataset.gift || '{}');
|
||||||
|
|
||||||
|
document.getElementById('modalAction').value = 'update';
|
||||||
|
document.getElementById('modalGiftId').value = data.id;
|
||||||
|
document.getElementById('modalChild').value = data.child_name;
|
||||||
|
document.getElementById('modalOccasion').value = data.occasion;
|
||||||
|
document.getElementById('modalDesc').value = data.gift_description;
|
||||||
|
document.getElementById('modalAmount').value = data.amount;
|
||||||
|
document.getElementById('modalLink').value = data.product_link;
|
||||||
|
|
||||||
|
const payerVal = data.payer_name || data.adult_name;
|
||||||
|
if (adultSelect && payerSelect) {
|
||||||
|
let adultExists = false, payerExists = false;
|
||||||
|
for(let i=0; i<adultSelect.options.length; i++) { if(adultSelect.options[i].value === data.adult_name) adultExists = true; }
|
||||||
|
for(let i=0; i<payerSelect.options.length; i++) { if(payerSelect.options[i].value === payerVal) payerExists = true; }
|
||||||
|
|
||||||
|
if (!adultExists) adultSelect.appendChild(new Option(data.adult_name, data.adult_name));
|
||||||
|
if (!payerExists) payerSelect.appendChild(new Option(payerVal, payerVal));
|
||||||
|
|
||||||
|
adultSelect.value = data.adult_name;
|
||||||
|
payerSelect.value = payerVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('modalTitle').textContent = window.I18N && window.I18N['gift_modal_title_edit'] ? window.I18N['gift_modal_title_edit'] : 'Modifier le cadeau';
|
||||||
|
|
||||||
|
if (modal) { modal.classList.add('open'); document.body.classList.add('no-scroll'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnDel = e.target.closest('.btn-delete-gift');
|
||||||
|
if (btnDel) {
|
||||||
|
const giftId = btnDel.dataset.id;
|
||||||
|
const msg = window.I18N && window.I18N['gift_confirm_delete'] ? window.I18N['gift_confirm_delete'] : 'Supprimer ce cadeau ?';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'delete');
|
||||||
|
fd.append('gift_id', giftId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/modules/gift-list/save-gift.php', { method: 'POST', body: fd });
|
||||||
|
window.location.reload();
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const giftForm = document.getElementById('giftForm');
|
||||||
|
if (giftForm) {
|
||||||
|
giftForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
const oldText = btn.innerText;
|
||||||
|
btn.innerText = '...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(e.target.getAttribute('action'), { method: 'POST', body: new FormData(e.target) });
|
||||||
|
window.location.reload();
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
btn.innerText = oldText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. GESTION DES PARAMÈTRES (MODALE FÊTES & PROCHES)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function closeGiftSettings() {
|
||||||
|
document.getElementById('modal-gift-settings').classList.remove('open');
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-open-gift-settings')?.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('modal-gift-settings').classList.add('open');
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
await loadSettingsData();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-close-settings').forEach(btn => btn.addEventListener('click', closeGiftSettings));
|
||||||
|
|
||||||
|
function switchGLTab(tabId) {
|
||||||
|
// Gestion du contenu (Afficher/Cacher)
|
||||||
|
document.getElementById('gl-pane-occasions').style.display = (tabId === 'occasions') ? 'block' : 'none';
|
||||||
|
document.getElementById('gl-pane-proches').style.display = (tabId === 'proches') ? 'block' : 'none';
|
||||||
|
|
||||||
|
// Gestion du style des boutons d'onglet
|
||||||
|
const btnOcc = document.getElementById('tab-btn-occasions');
|
||||||
|
const btnProche = document.getElementById('tab-btn-proches');
|
||||||
|
|
||||||
|
if (tabId === 'occasions') {
|
||||||
|
btnOcc.style.background = 'var(--bg-panel)';
|
||||||
|
btnOcc.style.color = 'var(--primary)';
|
||||||
|
btnOcc.style.boxShadow = 'var(--shadow-sm)';
|
||||||
|
|
||||||
|
btnProche.style.background = 'transparent';
|
||||||
|
btnProche.style.color = 'var(--text-muted)';
|
||||||
|
btnProche.style.boxShadow = 'none';
|
||||||
|
} else {
|
||||||
|
btnProche.style.background = 'var(--bg-panel)';
|
||||||
|
btnProche.style.color = 'var(--primary)';
|
||||||
|
btnProche.style.boxShadow = 'var(--shadow-sm)';
|
||||||
|
|
||||||
|
btnOcc.style.background = 'transparent';
|
||||||
|
btnOcc.style.color = 'var(--text-muted)';
|
||||||
|
btnOcc.style.boxShadow = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasSettingsChanged = false;
|
||||||
|
|
||||||
|
async function loadSettingsData() {
|
||||||
|
try {
|
||||||
|
const res = await pachaFetch('/modules/gift-list/api-settings.php?action=get_all');
|
||||||
|
if (!res.success) throw new Error(res.error);
|
||||||
|
|
||||||
|
renderOccasions(res.data.occasions);
|
||||||
|
renderProches(res.data.proches);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('occasions-list').innerHTML = `<div class="pf-alert pf-alert--error">Erreur : ${e.message}</div>`;
|
||||||
|
document.getElementById('proches-list').innerHTML = `<div class="pf-alert pf-alert--error">Erreur : ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOccasions(occasions) {
|
||||||
|
const container = document.getElementById('occasions-list');
|
||||||
|
if (!occasions.length) { container.innerHTML = `<em class="gift-empty-state">Vide</em>`; return; }
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
occasions.forEach(occ => {
|
||||||
|
const isChecked = occ.is_active == 1 ? 'checked' : '';
|
||||||
|
// ⚠️ Remarque l'ajout de id="row-occ-${occ.id}" ici :
|
||||||
|
html += `
|
||||||
|
<div class="gl-list-item" id="row-occ-${occ.id}">
|
||||||
|
<div>
|
||||||
|
<span style="font-size:1.2rem; margin-right:8px;">${occ.icon || '🎁'}</span>
|
||||||
|
<strong>${occ.name}</strong>
|
||||||
|
<small style="color:var(--text-muted); margin-left:8px;">${occ.month_date || ''}</small>
|
||||||
|
</div>
|
||||||
|
<div class="pf-flex-gap-8 align-center">
|
||||||
|
<label style="display:flex; align-items:center; cursor:pointer;">
|
||||||
|
<input type="checkbox" class="pf-checkbox-lg" ${isChecked} onchange="toggleOccasion(${occ.id}, this.checked)">
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn-icon-action edit" onclick="transformRowToEditOccasion('row-occ-${occ.id}', ${occ.id}, '${occ.name.replace(/'/g, "\\'")}', '${occ.icon}', '${occ.month_date || ''}')" title="${tr('btn_edit')}">✏️</button>
|
||||||
|
<button type="button" class="btn-icon-action delete" onclick="deleteOccasion(${occ.id})" title="${tr('btn_delete')}">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProches(proches) {
|
||||||
|
const container = document.getElementById('proches-list');
|
||||||
|
if (!proches.length) { container.innerHTML = `<em class="gift-empty-state">Aucun proche configuré.</em>`; return; }
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
proches.forEach(p => {
|
||||||
|
const roleLabel = p.role === 'proche_adulte' ? (tr('gift_role_proche_adulte') || 'Adulte') : (tr('gift_role_proche_enfant') || 'Enfant');
|
||||||
|
html += `
|
||||||
|
<div class="gl-list-item" id="row-proche-${p.id}">
|
||||||
|
<div><strong>${p.name}</strong> <small style="color:var(--text-muted);">— ${roleLabel}</small></div>
|
||||||
|
<div class="pf-flex-gap-8 align-center">
|
||||||
|
<button type="button" class="btn-icon-action edit" onclick="transformRowToEditProche('row-proche-${p.id}', ${p.id}, '${p.name.replace(/'/g, "\\'")}', '${p.role}')" title="${tr('btn_edit')}">✏️</button>
|
||||||
|
<button type="button" class="btn-icon-action delete" onclick="deleteProche(${p.id})" title="${tr('btn_delete')}">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CRUD : OCCASIONS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// 1. Ajouter une occasion
|
||||||
|
document.getElementById('form-add-occasion')?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
hasSettingsChanged = true;
|
||||||
|
|
||||||
|
const fd = new FormData(e.target);
|
||||||
|
fd.append('action', 'save_occasion');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
|
||||||
|
|
||||||
|
// On vide le formulaire
|
||||||
|
e.target.reset();
|
||||||
|
|
||||||
|
// On remet l'émoji par défaut visuellement
|
||||||
|
document.querySelectorAll('#form-add-occasion .emoji-option').forEach(el => el.classList.remove('selected'));
|
||||||
|
document.querySelector('#form-add-occasion .emoji-option').classList.add('selected');
|
||||||
|
document.getElementById('add-occ-icon').value = '🎁';
|
||||||
|
|
||||||
|
showToast("Fête ajoutée avec succès !", "success");
|
||||||
|
loadSettingsData(); // Recharge la liste
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Erreur lors de l'ajout", "error");
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Sauvegarder la modification (In-place)
|
||||||
|
async function saveInlineOccasion(id) {
|
||||||
|
hasSettingsChanged = true;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'save_occasion');
|
||||||
|
fd.append('id', id);
|
||||||
|
fd.append('icon', document.getElementById(`inline-occ-icon-${id}`).value);
|
||||||
|
fd.append('name', document.getElementById(`inline-occ-name-${id}`).value);
|
||||||
|
fd.append('month_date', document.getElementById(`inline-occ-date-${id}`).value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
|
||||||
|
showToast("Fête modifiée avec succès !", "success");
|
||||||
|
loadSettingsData();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Erreur lors de la modification", "error");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Supprimer une occasion
|
||||||
|
async function deleteOccasion(id) {
|
||||||
|
if (!confirm(tr('confirm_delete') || 'Supprimer définitivement ?')) return;
|
||||||
|
hasSettingsChanged = true;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'delete_occasion');
|
||||||
|
fd.append('id', id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
|
||||||
|
showToast("Fête supprimée.", "success");
|
||||||
|
loadSettingsData();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Erreur lors de la suppression", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Activer / Désactiver (Checkbox)
|
||||||
|
async function toggleOccasion(id, state) {
|
||||||
|
hasSettingsChanged = true;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'toggle_occasion');
|
||||||
|
fd.append('id', id);
|
||||||
|
fd.append('state', state ? 1 : 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
|
||||||
|
showToast(state ? "Fête activée" : "Fête masquée", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Erreur de mise à jour", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CRUD : PROCHES
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// 1. Ajouter un proche
|
||||||
|
document.getElementById('form-add-proche')?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
hasSettingsChanged = true;
|
||||||
|
|
||||||
|
const fd = new FormData(e.target);
|
||||||
|
fd.append('action', 'save_proche');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
|
||||||
|
e.target.reset();
|
||||||
|
showToast("Proche ajouté avec succès !", "success");
|
||||||
|
loadSettingsData();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Erreur lors de l'ajout", "error");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Sauvegarder la modification (In-place)
|
||||||
|
async function saveInlineProche(id) {
|
||||||
|
hasSettingsChanged = true;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'save_proche');
|
||||||
|
fd.append('id', id);
|
||||||
|
fd.append('name', document.getElementById(`inline-proche-name-${id}`).value);
|
||||||
|
fd.append('role', document.getElementById(`inline-proche-role-${id}`).value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
|
||||||
|
showToast("Proche mis à jour !", "success");
|
||||||
|
loadSettingsData();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Erreur lors de la mise à jour", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Supprimer un proche
|
||||||
|
async function deleteProche(id) {
|
||||||
|
if (!confirm(tr('confirm_delete') || 'Supprimer ce proche ?')) return;
|
||||||
|
hasSettingsChanged = true;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'delete_proche');
|
||||||
|
fd.append('id', id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
|
||||||
|
showToast("Proche retiré de la liste.", "success");
|
||||||
|
loadSettingsData();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Erreur lors de la suppression", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechargement intelligent lors de la fermeture si modifications
|
||||||
|
document.getElementById('modal-gift-settings').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'modal-gift-settings' || e.target.classList.contains('btn-close-settings')) {
|
||||||
|
if (hasSettingsChanged) {
|
||||||
|
showToast(tr('gift_settings_saved') || 'Paramètres mis à jour !', 'success');
|
||||||
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
closeGiftSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fonction pour gérer la sélection dans la grille d'émojis (formulaire d'ajout)
|
||||||
|
function selectEmoji(element, targetInputId, emoji) {
|
||||||
|
document.getElementById(targetInputId).value = emoji;
|
||||||
|
// Mise à jour visuelle de la sélection
|
||||||
|
const siblings = element.parentNode.querySelectorAll('.emoji-option');
|
||||||
|
siblings.forEach(el => el.classList.remove('selected'));
|
||||||
|
element.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LOGIQUE D'ÉDITION IN-PLACE (OCCASIONS)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// On crée une mémoire pour stocker le HTML d'origine de chaque ligne
|
||||||
|
const rowStates = {};
|
||||||
|
|
||||||
|
function transformRowToEditOccasion(rowId, id, name, icon, date) {
|
||||||
|
const row = document.getElementById(rowId);
|
||||||
|
|
||||||
|
// On sauvegarde l'état proprement en JS (fini les bugs d'affichage !)
|
||||||
|
rowStates[rowId] = row.innerHTML;
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; background: var(--bg-subtle); padding: 8px; border-radius: 8px; border: 1px solid var(--border-light);">
|
||||||
|
<select id="inline-occ-icon-${id}" class="pf-input" style="width: 60px; padding: 6px; margin: 0;">
|
||||||
|
<option value="🎁" ${icon==='🎁'?'selected':''}>🎁</option>
|
||||||
|
<option value="🎄" ${icon==='🎄'?'selected':''}>🎄</option>
|
||||||
|
<option value="🎂" ${icon==='🎂'?'selected':''}>🎂</option>
|
||||||
|
<option value="🎉" ${icon==='🎉'?'selected':''}>🎉</option>
|
||||||
|
<option value="🎓" ${icon==='🎓'?'selected':''}>🎓</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="inline-occ-name-${id}" value="${name}" class="pf-input" style="flex: 1; min-width: 100px; padding: 6px; margin: 0;">
|
||||||
|
<input type="text" id="inline-occ-date-${id}" value="${date}" class="pf-input" style="width: 70px; padding: 6px; margin: 0;" placeholder="MM-JJ">
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 4px; margin-left: auto;">
|
||||||
|
<button type="button" onclick="saveInlineOccasion(${id})" class="pf-btn pf-btn-primary" style="padding: 6px 10px;">💾</button>
|
||||||
|
<button type="button" onclick="cancelInlineEdit('${rowId}')" class="pf-btn btn-secondary" style="padding: 6px 10px;">❌</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveInlineOccasion(id) {
|
||||||
|
hasSettingsChanged = true;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'save_occasion');
|
||||||
|
fd.append('id', id);
|
||||||
|
fd.append('icon', document.getElementById(`inline-occ-icon-${id}`).value);
|
||||||
|
fd.append('name', document.getElementById(`inline-occ-name-${id}`).value);
|
||||||
|
fd.append('month_date', document.getElementById(`inline-occ-date-${id}`).value);
|
||||||
|
|
||||||
|
// On met à jour en BDD puis on recharge la liste !
|
||||||
|
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
|
||||||
|
loadSettingsData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LOGIQUE D'ÉDITION IN-PLACE (PROCHES)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function transformRowToEditProche(rowId, id, name, role) {
|
||||||
|
const row = document.getElementById(rowId);
|
||||||
|
rowStates[rowId] = row.innerHTML;
|
||||||
|
|
||||||
|
const roleAdulte = window.I18N ? window.I18N['gift_role_proche_adulte'] : 'Adulte';
|
||||||
|
const roleEnfant = window.I18N ? window.I18N['gift_role_proche_enfant'] : 'Enfant';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; background: var(--bg-subtle); padding: 8px; border-radius: 8px; border: 1px solid var(--border-light);">
|
||||||
|
<input type="text" id="inline-proche-name-${id}" value="${name}" class="pf-input" style="flex: 1; min-width: 100px; padding: 6px; margin: 0;">
|
||||||
|
<select id="inline-proche-role-${id}" class="pf-input" style="width: 130px; padding: 6px; margin: 0;">
|
||||||
|
<option value="proche_adulte" ${role==='proche_adulte'?'selected':''}>${roleAdulte}</option>
|
||||||
|
<option value="proche_enfant" ${role==='proche_enfant'?'selected':''}>${roleEnfant}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 4px; margin-left: auto;">
|
||||||
|
<button type="button" onclick="saveInlineProche(${id})" class="pf-btn pf-btn-primary" style="padding: 6px 10px;">💾</button>
|
||||||
|
<button type="button" onclick="cancelInlineEdit('${rowId}')" class="pf-btn btn-secondary" style="padding: 6px 10px;">❌</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveInlineProche(id) {
|
||||||
|
hasSettingsChanged = true;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'save_proche');
|
||||||
|
fd.append('id', id);
|
||||||
|
fd.append('name', document.getElementById(`inline-proche-name-${id}`).value);
|
||||||
|
fd.append('role', document.getElementById(`inline-proche-role-${id}`).value);
|
||||||
|
|
||||||
|
await pachaFetch('/modules/gift-list/api-settings.php', { method: 'POST', body: fd });
|
||||||
|
loadSettingsData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour restaurer la ligne (sans casser le HTML)
|
||||||
|
function cancelInlineEdit(rowId) {
|
||||||
|
document.getElementById(rowId).innerHTML = rowStates[rowId];
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = tr('mod_groceries_name') . ' — HouseHub';
|
||||||
|
$activePage = 'groceries';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/groceries/assets/groceries.css">
|
||||||
|
<div id="groceries-toasts" class="groceries-toast-container"></div>
|
||||||
|
|
||||||
|
<div class="groceries-layout">
|
||||||
|
<div class="groceries-hero">
|
||||||
|
<h1>🛒 <?= htmlspecialchars(tr('mod_groceries_name')) ?></h1>
|
||||||
|
<p><?= htmlspecialchars(tr('mod_groceries_desc')) ?></p>
|
||||||
|
<p id="groceries-subtitle" class="pf-muted-note" style="margin-top:0.5rem;font-size:0.85rem"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="groceries-add-card">
|
||||||
|
<div class="groceries-add-card-inner">
|
||||||
|
<div class="groceries-quick-add">
|
||||||
|
<input type="text" id="groceries-new" class="pf-input groceries-add-input" placeholder="<?= htmlspecialchars(tr('groceries_placeholder_add')) ?>" autocomplete="off" maxlength="500" aria-label="<?= htmlspecialchars(tr('groceries_placeholder_add')) ?>">
|
||||||
|
<button type="button" class="pf-btn groceries-add-submit" onclick="addFromInput()"><?= htmlspecialchars(tr('groceries_btn_add')) ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="groceries-toolbar">
|
||||||
|
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn" onclick="uncheckAll()"><?= htmlspecialchars(tr('groceries_btn_uncheck_all')) ?></button>
|
||||||
|
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--danger" onclick="deletePicked()"><?= htmlspecialchars(tr('groceries_btn_delete_picked')) ?></button>
|
||||||
|
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--outline" onclick="clearWholeList()"><?= htmlspecialchars(tr('groceries_btn_clear_list')) ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="groceries-lists"></div>
|
||||||
|
|
||||||
|
<div class="groceries-history-card" id="groceries-history-root" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/modules/groceries/assets/groceries.js"></script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -1,29 +1,252 @@
|
|||||||
<?php
|
<?php
|
||||||
if (!isset($pageTitle)) {
|
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||||
$pageTitle = "PachaFamily";
|
|
||||||
}
|
// 1. Inclusion du moteur de traduction
|
||||||
if (!isset($activePage)) {
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
$activePage = "";
|
|
||||||
|
/**
|
||||||
|
* Génère une URL avec le paramètre de langue mis à jour
|
||||||
|
*/
|
||||||
|
function getLangUrl($newLang) {
|
||||||
|
$params = $_GET;
|
||||||
|
$params['lang'] = $newLang;
|
||||||
|
return '?' . http_build_query($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$pageTitle = $pageTitle ?? "HouseHub";
|
||||||
|
$activePage = $activePage ?? "";
|
||||||
|
$currentLang = $_SESSION['app_lang'] ?? 'fr';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="<?= htmlspecialchars($currentLang) ?>">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<title><?php echo htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8'); ?></title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<link rel="stylesheet" href="/assets/css/style.css">
|
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.png">
|
||||||
|
<link rel="stylesheet" href="/global.css">
|
||||||
|
<?php if (!empty($pageCss)): ?>
|
||||||
|
<link rel="stylesheet" href="<?= htmlspecialchars($pageCss) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<link rel="stylesheet" href="/dark-mode.css">
|
||||||
|
<script>(function(){const t=localStorage.getItem('hh-theme');if(t==='dark')document.documentElement.setAttribute('data-theme','dark');})()</script>
|
||||||
|
<style>
|
||||||
|
/* Patch CSS pour la zone droite de l'en-tête (Desktop / Mobile) */
|
||||||
|
.pf-header-right { display: flex; align-items: center; gap: 15px; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pf-desktop-actions { display: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-page="<?= htmlspecialchars($activePage ?? '') ?>">
|
||||||
|
|
||||||
<header class="pf-header">
|
<header class="pf-header">
|
||||||
<div class="pf-container pf-header-content">
|
<a href="/index.php" class="pf-logo">HouseHub</a>
|
||||||
<div class="pf-logo">PachaFamily</div>
|
|
||||||
<nav class="pf-nav">
|
<?php if (isset($_SESSION['user'])): ?>
|
||||||
<a href="/index.php"
|
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','calendar_ios']; ?>
|
||||||
class="pf-nav-link <?php echo $activePage === 'home' ? 'pf-nav-link--active' : ''; ?>">Accueil</a>
|
<nav class="pf-nav">
|
||||||
<a href="/family-calendar.php"
|
<a href="/index.php" class="pf-nav-link <?= $activePage === 'home' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_home') ?></a>
|
||||||
class="pf-nav-link <?php echo $activePage === 'family-calendar' ? 'pf-nav-link--active' : ''; ?>">Family calendar</a>
|
<?php if (in_array('calendar', $mods)): ?><a href="/family-calendar.php" class="pf-nav-link <?= $activePage === 'family-calendar' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar') ?></a><?php endif; ?>
|
||||||
</nav>
|
<?php if (in_array('budget', $mods)): ?><a href="/budget.php" class="pf-nav-link <?= $activePage === 'budget' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_budget') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-nav-link <?= $activePage === 'holidays' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_holidays') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-nav-link <?= $activePage === 'gift-list' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_gifts') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('liste', $mods)): ?><a href="/liste.php" class="pf-nav-link <?= $activePage === 'liste' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_liste') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-nav-link <?= $activePage === 'printvault' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_printvault') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-nav-link <?= $activePage === 'planka' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_planka') ?></a><?php endif; ?>
|
||||||
|
</nav>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="pf-header-right">
|
||||||
|
<button class="theme-switch" id="theme-toggle" onclick="toggleTheme()" title="Thème sombre / clair" aria-label="Basculer le thème">
|
||||||
|
<span class="ts-sun" aria-hidden="true">☀️</span>
|
||||||
|
<span class="ts-track"><span class="ts-thumb"></span></span>
|
||||||
|
<span class="ts-moon" aria-hidden="true">🌙</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['user'])): ?>
|
||||||
|
<div class="pf-desktop-actions" style="display: flex; align-items: center; gap: 10px; border-left: 1px solid #cbd5e1; padding-left: 15px;">
|
||||||
|
<a href="/settings.php" class="pf-user-badge" style="text-decoration:none;color:inherit" title="Paramètres">
|
||||||
|
⚙️ <?= htmlspecialchars($_SESSION['user']['display_name'] ?? $_SESSION['user']['username']) ?>
|
||||||
|
</a>
|
||||||
|
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
|
||||||
|
<a href="/admin/" class="pf-nav-link" style="font-size:0.85rem;color:#2563eb" title="Admin">Admin</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="/logout.php" class="pf-logout-btn"><?= tr('btn_logout') ?></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="pf-burger-btn" aria-label="<?= tr('aria_menu') ?>" style="margin-left: 5px;">☰</button>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="/login.php" class="pf-nav-link" style="border-left: 1px solid #cbd5e1; padding-left: 15px; margin-left: 5px;"><?= tr('btn_login') ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="pf-container pf-main">
|
<?php if (isset($_SESSION['user'])): ?>
|
||||||
|
<div class="pf-mobile-overlay" id="mobileOverlay"></div>
|
||||||
|
|
||||||
|
<div class="pf-mobile-menu" id="mobileMenu">
|
||||||
|
<div class="pf-mobile-menu-header">
|
||||||
|
<span class="pf-logo">HouseHub</span>
|
||||||
|
<button class="pf-mobile-close-btn" id="closeMobileMenu">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-mobile-menu-body">
|
||||||
|
<a href="/index.php" class="pf-mobile-nav-link">🏠 <?= tr('menu_home') ?></a>
|
||||||
|
<a href="/family-calendar.php" class="pf-mobile-nav-link">📅 <?= tr('menu_calendar') ?></a>
|
||||||
|
<?php if (in_array('budget', $mods)): ?><a href="/budget.php" class="pf-mobile-nav-link">💰 <?= tr('menu_budget') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-mobile-nav-link">🏖️ <?= tr('menu_holidays') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-mobile-nav-link">🎁 <?= tr('menu_gifts') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('liste',$mods)): ?><a href="/liste.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_liste') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-mobile-nav-link">🖨️ <?= tr('menu_printvault') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-mobile-nav-link">📋 <?= tr('menu_planka') ?></a><?php endif; ?>
|
||||||
|
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
|
||||||
|
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
|
||||||
|
<a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="/logout.php" class="pf-mobile-nav-link pf-mobile-logout" style="margin-top: auto;">🚪 <?= tr('btn_logout') ?></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<main class="pf-main<?= ($mainClass ?? '') ? ' '.htmlspecialchars($mainClass) : '' ?>">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// CSRF avant I18N : le parseur HTML coupe le script dès la séquence de fin de balise « script » (même dans un commentaire JS).
|
||||||
|
window.CSRF_TOKEN = "<?= htmlspecialchars(function_exists('csrf_token') ? csrf_token() : '', ENT_QUOTES, 'UTF-8') ?>";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pont d'Internationalisation et Configuration
|
||||||
|
*/
|
||||||
|
window.I18N = <?php echo json_encode($current_translations_array ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE); ?>;
|
||||||
|
|
||||||
|
// ✅ Injection sécurisée
|
||||||
|
window.CONFIG = {
|
||||||
|
// Liste complète des profils de la famille (Membres, Nounou, Enfants)
|
||||||
|
PEOPLE: <?= isset($familyPeople) ? json_encode($familyPeople) : '[]' ?>,
|
||||||
|
// ID du user global connecté (pour savoir "qui" clique)
|
||||||
|
CURRENT_USER_ID: <?= isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : 'null' ?>,
|
||||||
|
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>',
|
||||||
|
ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>'
|
||||||
|
};
|
||||||
|
|
||||||
|
function tr(key) {
|
||||||
|
return window.I18N[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thème clair/sombre — état visuel géré entièrement par CSS via [data-theme="dark"]
|
||||||
|
function toggleTheme(){
|
||||||
|
const dark=document.documentElement.getAttribute('data-theme')==='dark';
|
||||||
|
document.documentElement.setAttribute('data-theme',dark?'light':'dark');
|
||||||
|
localStorage.setItem('hh-theme',dark?'light':'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion du menu mobile (Off-Canvas Sidebar)
|
||||||
|
<?php if (isset($_SESSION['user'])): ?>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const burgerBtn = document.querySelector('.pf-burger-btn');
|
||||||
|
const mobileMenu = document.getElementById('mobileMenu');
|
||||||
|
const overlay = document.getElementById('mobileOverlay');
|
||||||
|
const closeBtn = document.getElementById('closeMobileMenu');
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
mobileMenu.classList.add('is-open');
|
||||||
|
overlay.classList.add('is-visible');
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
mobileMenu.classList.remove('is-open');
|
||||||
|
overlay.classList.remove('is-visible');
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
if(burgerBtn && mobileMenu && overlay && closeBtn) {
|
||||||
|
burgerBtn.addEventListener('click', openMenu);
|
||||||
|
closeBtn.addEventListener('click', closeMenu);
|
||||||
|
// Fermer le menu si on clique en dehors (sur l'overlay sombre)
|
||||||
|
overlay.addEventListener('click', closeMenu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pachaFetch : Utilitaire de requête robuste
|
||||||
|
*/
|
||||||
|
async function pachaFetch(url, options = {}) {
|
||||||
|
const finalUrl = url.startsWith('/') ? url.substring(1) : url;
|
||||||
|
|
||||||
|
options.credentials = 'same-origin';
|
||||||
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(finalUrl, options);
|
||||||
|
const rawText = await response.text();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawText);
|
||||||
|
} catch (jsonErr) {
|
||||||
|
console.error("Réponse corrompue (HTML reçu au lieu de JSON) :", rawText);
|
||||||
|
throw new Error("Erreur serveur : format JSON invalide.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Erreur pachaFetch [${finalUrl}] :`, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI Utility : Toasts (Notifications)
|
||||||
|
*/
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `pf-toast pf-toast--${type}`;
|
||||||
|
toast.innerHTML = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
// Animation et suppression
|
||||||
|
setTimeout(() => toast.classList.add('is-visible'), 100);
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('is-visible');
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI Utility : Confirmation stylisée (Remplace confirm())
|
||||||
|
*/
|
||||||
|
async function pachaConfirm(title, message) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'pf-modal open';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="pf-modal-content" style="max-width: 400px; align-self: center;">
|
||||||
|
<h3 style="margin-top:0;">${title}</h3>
|
||||||
|
<p style="color:var(--text-muted);">${message}</p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="pf-btn btn-secondary" id="confirm-cancel">${tr('btn_cancel')}</button>
|
||||||
|
<button class="pf-btn" id="confirm-ok" style="background:var(--danger);">${tr('btn_delete')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
document.getElementById('confirm-cancel').onclick = () => { modal.remove(); resolve(false); };
|
||||||
|
document.getElementById('confirm-ok').onclick = () => { modal.remove(); resolve(true); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// holidays.php
|
||||||
|
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login('/login.php');
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require_once __DIR__ . '/includes/i18n.php'; // Toujours s'assurer de l'accès à tr()
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) { session_start(); }
|
||||||
|
|
||||||
|
// 1. On récupère la vue demandée (par défaut 'list')
|
||||||
|
$tab = $_GET['tab'] ?? 'list';
|
||||||
|
|
||||||
|
// 2. Configuration des variables pour le header (Titres traduits)
|
||||||
|
$pageTitle = ($tab === 'holiday_detail')
|
||||||
|
? tr('hdl_title_detail')
|
||||||
|
: tr('hdl_title_list');
|
||||||
|
|
||||||
|
$activePage = "holidays";
|
||||||
|
$mainClass = "pf-holidays";
|
||||||
|
$pageCss = "/modules/holidays/holidays.css";
|
||||||
|
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
|
||||||
|
$stmtVehicles = $pdo->query("SELECT id, name FROM pf_vehicles ORDER BY name ASC");
|
||||||
|
$garageVehicles = $stmtVehicles->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// 3. ROUTEUR DU MODULE VACANCES
|
||||||
|
if ($tab === 'holiday_detail' && isset($_GET['id'])) {
|
||||||
|
// Si on demande le détail ET qu'un ID est fourni
|
||||||
|
require __DIR__ . '/modules/holidays/views/detail.php';
|
||||||
|
} else {
|
||||||
|
// Vue par défaut : la liste des cartes
|
||||||
|
require __DIR__ . '/modules/holidays/views/list.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Inclusion des librairies globales du module (Flatpickr)
|
||||||
|
?>
|
||||||
|
<!-- Flatpickr (Vanilla JS Date Range) -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css" id="flatpickr-dark-theme" disabled>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/fr.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/cat.js"></script>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$jsPath = __DIR__ . '/modules/holidays/holidays.js';
|
||||||
|
$jsVersion = file_exists($jsPath) ? filemtime($jsPath) : time();
|
||||||
|
echo '<script src="/modules/holidays/holidays.js?v=' . $jsVersion . '"></script>';
|
||||||
|
|
||||||
|
require __DIR__ . '/footer.php';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
$family_id = $_SESSION['user']['family_id'];
|
||||||
|
if (!$family_id) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
$file = null;
|
||||||
|
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $f) { $file = $f; break; }
|
||||||
|
|
||||||
|
if (!$file || !file_exists($file)) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||||
|
$mime = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','webp'=>'image/webp'][$ext] ?? 'image/jpeg';
|
||||||
|
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
readfile($file);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
if (empty($_SESSION['user']['is_admin'])) {
|
||||||
|
http_response_code(403);
|
||||||
|
die('<h1>403 — Accès réservé aux administrateurs</h1><a href="/">Retour</a>');
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie qu'un utilisateur est connecté.
|
||||||
|
* Si non, le redirige vers la page de login avec ?redirect=URL_DEMANDEE
|
||||||
|
*
|
||||||
|
* @param string|null $loginPage URL de la page de login (par défaut /login.php)
|
||||||
|
*/
|
||||||
|
function require_login(?string $loginPage = '/login.php'): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_SESSION['user'])) {
|
||||||
|
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false)) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Session expirée. Veuillez vous reconnecter.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUrl = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$redirectParam = urlencode($currentUrl);
|
||||||
|
$target = ($loginPage ?? '/login.php') . '?redirect=' . $redirectParam;
|
||||||
|
|
||||||
|
header('Location: ' . $target);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrf_token(): string
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function verify_csrf(?string $token): bool
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (empty($_SESSION['csrf_token']) || !$token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hash_equals($_SESSION['csrf_token'], $token);
|
||||||
|
}
|
||||||
|
if (!isset($familyPeople) && !empty($family_id)) {
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
$stmtPeople = $pdo->query("SELECT id, name, user_id FROM pf_people ORDER BY id ASC");
|
||||||
|
$familyPeople = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matériau de clé pour chiffrer les secrets (mot de passe d’app iCloud, etc.).
|
||||||
|
* Ordre de priorité :
|
||||||
|
* 1. Variable d’environnement APP_SECRET_KEY (recommandé en production)
|
||||||
|
* 2. Fichier data/.hh_app_secret (généré automatiquement au premier besoin si le dossier est inscriptible)
|
||||||
|
* 3. Dérivation stable depuis DB_PASS + DB_HOST (évite l’erreur si rien n’est configuré ; change si le mot de passe DB change)
|
||||||
|
*/
|
||||||
|
function hh_secret_key_material(): string
|
||||||
|
{
|
||||||
|
$fromEnv = trim((string) getenv('APP_SECRET_KEY'));
|
||||||
|
if ($fromEnv !== '') {
|
||||||
|
return $fromEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyFile = dirname(__DIR__) . '/data/.hh_app_secret';
|
||||||
|
if (is_readable($keyFile)) {
|
||||||
|
$content = trim((string) file_get_contents($keyFile));
|
||||||
|
if ($content !== '' && strlen($content) >= 32) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = dirname($keyFile);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0700, true);
|
||||||
|
}
|
||||||
|
if (is_dir($dir) && is_writable($dir)) {
|
||||||
|
try {
|
||||||
|
$material = bin2hex(random_bytes(32));
|
||||||
|
if (@file_put_contents($keyFile, $material, LOCK_EX) !== false) {
|
||||||
|
@chmod($keyFile, 0600);
|
||||||
|
return $material;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// continuer vers la dérivation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbPass = (string) (getenv('DB_PASS') ?: '');
|
||||||
|
$dbHost = (string) (getenv('DB_HOST') ?: '');
|
||||||
|
return 'hh-derived|' . $dbPass . '|' . $dbHost . '|HouseHub-calendar-ios-v1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_encryption_key(): string
|
||||||
|
{
|
||||||
|
return hash('sha256', hh_secret_key_material(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_encrypt_secret(string $plain): string
|
||||||
|
{
|
||||||
|
$key = hh_encryption_key();
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipher = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
if ($cipher === false) {
|
||||||
|
throw new RuntimeException('Chiffrement impossible');
|
||||||
|
}
|
||||||
|
return base64_encode($iv . $cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_decrypt_secret(string $encrypted): string
|
||||||
|
{
|
||||||
|
$key = hh_encryption_key();
|
||||||
|
$raw = base64_decode($encrypted, true);
|
||||||
|
if ($raw === false || strlen($raw) <= 16) {
|
||||||
|
throw new RuntimeException('Secret invalide');
|
||||||
|
}
|
||||||
|
$iv = substr($raw, 0, 16);
|
||||||
|
$cipher = substr($raw, 16);
|
||||||
|
$plain = openssl_decrypt($cipher, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
if ($plain === false) {
|
||||||
|
throw new RuntimeException('Déchiffrement impossible');
|
||||||
|
}
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
@@ -1,27 +1,37 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// includes/db.php
|
||||||
|
|
||||||
|
if (file_exists(__DIR__ . '/config.php')) {
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
}
|
||||||
|
|
||||||
// Activer erreurs
|
|
||||||
ini_set('display_errors', 1);
|
ini_set('display_errors', 1);
|
||||||
ini_set('display_startup_errors', 1);
|
ini_set('display_startup_errors', 1);
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
// Détection de l'environnement
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
$isLocal = (strpos($_SERVER['HTTP_HOST'] ?? 'localhost', 'localhost') !== false ||
|
session_start();
|
||||||
strpos($_SERVER['HTTP_HOST'] ?? '', '127.0.0.1') !== false ||
|
}
|
||||||
strpos($_SERVER['HTTP_HOST'] ?? '', '::1') !== false);
|
|
||||||
|
|
||||||
if ($isLocal) {
|
$envHost = getenv('DB_HOST');
|
||||||
// Configuration XAMPP local
|
|
||||||
$host = 'localhost';
|
// 1. Détection stricte de l'environnement
|
||||||
$db = 'percolo314'; // Même nom que sur OVH pour simplifier
|
if ($envHost === 'househub-db') {
|
||||||
$user = 'root';
|
// Docker
|
||||||
$pass = ''; // Généralement vide sur XAMPP
|
$host = 'househub-db';
|
||||||
|
$user = getenv('DB_USER') ?: 'househub';
|
||||||
|
$pass = getenv('DB_PASS') ?: 'changeme';
|
||||||
} else {
|
} else {
|
||||||
// Configuration serveur OVH
|
// XAMPP
|
||||||
$host = 'percolo314.mysql.db';
|
$host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
|
||||||
$db = 'percolo314';
|
$user = 'root';
|
||||||
$user = 'percolo314';
|
$pass = '';
|
||||||
$pass = 'Wxcvbn99';
|
}
|
||||||
|
|
||||||
|
// 2. Multi-tenant strict : cibler la DB de la famille courante
|
||||||
|
$db = 'househub_meta'; // Fallback par défaut
|
||||||
|
if (!empty($_SESSION['user']['family_id'])) {
|
||||||
|
$db = 'househub_f' . (int)$_SESSION['user']['family_id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$charset = 'utf8mb4';
|
$charset = 'utf8mb4';
|
||||||
@@ -43,12 +53,7 @@ try {
|
|||||||
$pdo->exec("SET collation_database = utf8mb4_general_ci");
|
$pdo->exec("SET collation_database = utf8mb4_general_ci");
|
||||||
$pdo->exec("SET collation_server = utf8mb4_general_ci");
|
$pdo->exec("SET collation_server = utf8mb4_general_ci");
|
||||||
|
|
||||||
// Debug optionnel (à commenter en production)
|
|
||||||
// $env = $isLocal ? 'LOCAL' : 'SERVEUR';
|
|
||||||
// echo "<!-- Connecté en $env sur $host/$db -->";
|
|
||||||
|
|
||||||
} catch (\PDOException $e) {
|
} catch (\PDOException $e) {
|
||||||
$environment = $isLocal ? 'local (XAMPP)' : 'serveur (OVH)';
|
die("Erreur de connexion BDD (Host: $host, DB: $db) : " . $e->getMessage());
|
||||||
die("Erreur de connexion ($environment) : " . $e->getMessage());
|
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
// Démarrer la session si ce n'est pas déjà fait (souvent fait dans auth.php, mais on sécurise)
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Changement de langue si l'utilisateur a cliqué sur un drapeau
|
||||||
|
if (isset($_GET['lang']) && in_array($_GET['lang'], ['fr', 'ca', 'en'])) {
|
||||||
|
$_SESSION['app_lang'] = $_GET['lang'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Langue par défaut = Français
|
||||||
|
$current_lang = $_SESSION['app_lang'] ?? 'fr';
|
||||||
|
|
||||||
|
// 3. Chargement du bon dictionnaire
|
||||||
|
$lang_file = __DIR__ . '/lang/' . $current_lang . '.php';
|
||||||
|
if (file_exists($lang_file)) {
|
||||||
|
$GLOBALS['translations'] = include $lang_file;
|
||||||
|
} else {
|
||||||
|
$GLOBALS['translations'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. La fonction magique de traduction
|
||||||
|
// Si le mot existe dans le dictionnaire on l'affiche, sinon on affiche la clé par défaut
|
||||||
|
if (!function_exists('tr')) {
|
||||||
|
function tr($key) {
|
||||||
|
return $GLOBALS['translations'][$key] ?? $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export pour le client (header.php → window.I18N) : pachaConfirm, modules JS, etc.
|
||||||
|
$current_translations_array = $GLOBALS['translations'] ?? [];
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise le mot de passe d’application Apple (supprime les espaces, garde les tirets).
|
||||||
|
*/
|
||||||
|
function hh_normalize_apple_app_password(string $password): string
|
||||||
|
{
|
||||||
|
return preg_replace('/\s+/', '', trim($password));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_caldav_url_is_icloud(string $url): bool
|
||||||
|
{
|
||||||
|
$h = strtolower((string) (parse_url(trim($url), PHP_URL_HOST) ?: ''));
|
||||||
|
return str_contains($h, 'icloud.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_caldav_is_icloud_well_known_root(string $url): bool
|
||||||
|
{
|
||||||
|
$parts = parse_url(rtrim(trim($url), '/'));
|
||||||
|
if (empty($parts['scheme']) || empty($parts['host'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$host = strtolower($parts['host']);
|
||||||
|
if ($host !== 'caldav.icloud.com') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$path = $parts['path'] ?? '';
|
||||||
|
return $path === '' || $path === '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_caldav_resolve_href(string $againstUrl, string $href): string
|
||||||
|
{
|
||||||
|
$href = trim($href);
|
||||||
|
if ($href === '') {
|
||||||
|
return $againstUrl;
|
||||||
|
}
|
||||||
|
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
|
||||||
|
return $href;
|
||||||
|
}
|
||||||
|
$parts = parse_url($againstUrl);
|
||||||
|
$scheme = $parts['scheme'] ?? 'https';
|
||||||
|
$host = $parts['host'] ?? '';
|
||||||
|
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
|
||||||
|
if ($href[0] === '/') {
|
||||||
|
return $scheme . '://' . $host . $port . $href;
|
||||||
|
}
|
||||||
|
$base = preg_replace('#/[^/]*$#', '/', $againstUrl);
|
||||||
|
return rtrim($base, '/') . '/' . ltrim($href, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{code:int, body:string}
|
||||||
|
*/
|
||||||
|
function hh_caldav_propfind(string $url, string $username, string $password, string $xml, string $depth = '0'): array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 25,
|
||||||
|
CURLOPT_CUSTOMREQUEST => 'PROPFIND',
|
||||||
|
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
|
||||||
|
CURLOPT_USERPWD => $username . ':' . $password,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/xml; charset=utf-8',
|
||||||
|
'Depth: ' . $depth,
|
||||||
|
],
|
||||||
|
CURLOPT_POSTFIELDS => $xml,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 10,
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($body === false) {
|
||||||
|
return ['code' => 0, 'body' => ''];
|
||||||
|
}
|
||||||
|
return ['code' => $code, 'body' => $body];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_caldav_first_href_by_element(string $xml, string $parentLocalName): ?string
|
||||||
|
{
|
||||||
|
$dom = new DOMDocument();
|
||||||
|
if (!@$dom->loadXML($xml)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$xpath = new DOMXPath($dom);
|
||||||
|
$nodes = $xpath->query("//*[local-name()='{$parentLocalName}']//*[local-name()='href']");
|
||||||
|
if ($nodes->length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trim($nodes->item(0)->textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{href:string, display:string}>
|
||||||
|
*/
|
||||||
|
function hh_caldav_parse_calendar_collections(string $xml): array
|
||||||
|
{
|
||||||
|
$dom = new DOMDocument();
|
||||||
|
if (!@$dom->loadXML($xml)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$xpath = new DOMXPath($dom);
|
||||||
|
$responses = $xpath->query("//*[local-name()='response']");
|
||||||
|
$out = [];
|
||||||
|
foreach ($responses as $resp) {
|
||||||
|
$hrefNodes = $xpath->query(".//*[local-name()='href']", $resp);
|
||||||
|
if ($hrefNodes->length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$href = trim($hrefNodes->item(0)->textContent);
|
||||||
|
$cal = $xpath->query(".//*[local-name()='resourcetype']/*[local-name()='calendar']", $resp);
|
||||||
|
if ($cal->length === 0 || $href === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$display = '';
|
||||||
|
$dn = $xpath->query(".//*[local-name()='displayname']", $resp);
|
||||||
|
if ($dn->length > 0) {
|
||||||
|
$display = trim($dn->item(0)->textContent);
|
||||||
|
}
|
||||||
|
$out[] = ['href' => $href, 'display' => $display];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les collections calendrier iCloud (URL complète avec / final + nom affiché).
|
||||||
|
*
|
||||||
|
* @return list<array{display:string, url:string}>
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
function hh_icloud_discover_calendar_entries(string $username, string $password): array
|
||||||
|
{
|
||||||
|
$password = hh_normalize_apple_app_password($password);
|
||||||
|
$root = 'https://caldav.icloud.com/';
|
||||||
|
|
||||||
|
$xml1 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<propfind xmlns="DAV:">'
|
||||||
|
. '<prop><current-user-principal/></prop>'
|
||||||
|
. '</propfind>';
|
||||||
|
$r1 = hh_caldav_propfind($root, $username, $password, $xml1, '0');
|
||||||
|
if ($r1['code'] === 401 || $r1['code'] === 403) {
|
||||||
|
throw new RuntimeException('Identifiant Apple ou mot de passe d’app refusé (HTTP ' . $r1['code'] . ').');
|
||||||
|
}
|
||||||
|
if (!in_array($r1['code'], [200, 207], true)) {
|
||||||
|
throw new RuntimeException('CalDAV racine inaccessible (HTTP ' . $r1['code'] . ').');
|
||||||
|
}
|
||||||
|
$principalHref = hh_caldav_first_href_by_element($r1['body'], 'current-user-principal');
|
||||||
|
if (!$principalHref) {
|
||||||
|
throw new RuntimeException('Réponse CalDAV inattendue : principal introuvable.');
|
||||||
|
}
|
||||||
|
$principalUrl = hh_caldav_resolve_href($root, $principalHref);
|
||||||
|
|
||||||
|
$xml2 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
. '<prop><C:calendar-home-set/></prop>'
|
||||||
|
. '</propfind>';
|
||||||
|
$r2 = hh_caldav_propfind($principalUrl, $username, $password, $xml2, '0');
|
||||||
|
if (!in_array($r2['code'], [200, 207], true)) {
|
||||||
|
throw new RuntimeException('Lecture du principal CalDAV impossible (HTTP ' . $r2['code'] . ').');
|
||||||
|
}
|
||||||
|
$homeHref = hh_caldav_first_href_by_element($r2['body'], 'calendar-home-set');
|
||||||
|
if (!$homeHref) {
|
||||||
|
throw new RuntimeException('Aucun dossier de calendriers (calendar-home-set) trouvé.');
|
||||||
|
}
|
||||||
|
$homeUrl = hh_caldav_resolve_href($principalUrl, $homeHref);
|
||||||
|
|
||||||
|
$xml3 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<propfind xmlns="DAV:">'
|
||||||
|
. '<prop><resourcetype/><displayname/></prop>'
|
||||||
|
. '</propfind>';
|
||||||
|
$r3 = hh_caldav_propfind($homeUrl, $username, $password, $xml3, '1');
|
||||||
|
if (!in_array($r3['code'], [200, 207], true)) {
|
||||||
|
throw new RuntimeException('Liste des calendriers impossible (HTTP ' . $r3['code'] . ').');
|
||||||
|
}
|
||||||
|
$raw = hh_caldav_parse_calendar_collections($r3['body']);
|
||||||
|
if ($raw === []) {
|
||||||
|
throw new RuntimeException('Aucun calendrier trouvé sur ce compte iCloud.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($raw as $e) {
|
||||||
|
$url = rtrim(hh_caldav_resolve_href($homeUrl, $e['href']), '/') . '/';
|
||||||
|
$out[] = ['display' => $e['display'], 'url' => $url];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depuis la racine iCloud (https://caldav.icloud.com), découvre l’URL d’un calendrier (par défaut « Calendar » / « Calendrier », sinon le premier).
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
function hh_icloud_discover_default_calendar_url(string $username, string $password): string
|
||||||
|
{
|
||||||
|
$entries = hh_icloud_discover_calendar_entries($username, $password);
|
||||||
|
$preferred = ['calendar', 'calendrier', 'home', 'par défaut', 'default'];
|
||||||
|
foreach ($entries as $e) {
|
||||||
|
$lower = mb_strtolower($e['display']);
|
||||||
|
foreach ($preferred as $p) {
|
||||||
|
if ($lower !== '' && str_contains($lower, $p)) {
|
||||||
|
return $e['url'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $entries[0]['url'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie qu’une URL de collection calendrier répond en CalDAV (PROPFIND).
|
||||||
|
*/
|
||||||
|
function hh_caldav_test_calendar_collection(string $username, string $password, string $calendarUrl): int
|
||||||
|
{
|
||||||
|
$password = hh_normalize_apple_app_password($password);
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
. '<prop><resourcetype/><displayname/><C:supported-calendar-component-set/></prop>'
|
||||||
|
. '</propfind>';
|
||||||
|
$r = hh_caldav_propfind(rtrim($calendarUrl, '/') . '/', $username, $password, $xml, '0');
|
||||||
|
return $r['code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si URL = racine iCloud bien connue, découvre et retourne l’URL d’un calendrier ; sinon retourne l’URL telle quelle (trim).
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
function hh_icloud_resolve_calendar_url_if_needed(string $username, string $password, string $calendarUrl): string
|
||||||
|
{
|
||||||
|
$calendarUrl = trim($calendarUrl);
|
||||||
|
if (hh_caldav_is_icloud_well_known_root($calendarUrl)) {
|
||||||
|
return hh_icloud_discover_default_calendar_url($username, $password);
|
||||||
|
}
|
||||||
|
return $calendarUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,983 @@
|
|||||||
|
<?php
|
||||||
|
// Dictionnaire Catalan
|
||||||
|
return [
|
||||||
|
// ==========================================
|
||||||
|
// TEST AUTO
|
||||||
|
// ==========================================
|
||||||
|
'test_running' => 'Proves en curs...',
|
||||||
|
'test_passed' => 'Prova superada',
|
||||||
|
'test_failed' => 'Prova fallida',
|
||||||
|
'tests_title' => 'Laboratori de Proves',
|
||||||
|
'tests_run_budget' => 'Provar el Pressupost',
|
||||||
|
'tests_run_all' => 'Iniciar totes les proves',
|
||||||
|
'tests_copy_report' => 'Copiar l\'informe',
|
||||||
|
'tests_waiting' => 'Esperant l\'inici...',
|
||||||
|
'tests_report_copied' => 'Informe copiat al porta-retalls!',
|
||||||
|
'tests_live_preview' => 'Previsualització en viu',
|
||||||
|
// ==========================================
|
||||||
|
// GLOBAL & NAVIGATION
|
||||||
|
// ==========================================
|
||||||
|
'menu_home' => 'Inici',
|
||||||
|
'menu_calendar' => 'Calendari',
|
||||||
|
'menu_budget' => 'Pressupost',
|
||||||
|
'menu_holidays' => 'Viatges',
|
||||||
|
'menu_gifts' => 'Regals',
|
||||||
|
'menu_calendar_ios' => 'Calendari iOS',
|
||||||
|
'nav_home' => 'Inici',
|
||||||
|
'nav_calendar' => 'Calendari',
|
||||||
|
'nav_budget' => 'Pressupost',
|
||||||
|
'nav_holidays' => 'Viatges',
|
||||||
|
'nav_gifts' => 'Regals',
|
||||||
|
'aria_menu' => 'Menú mòbil',
|
||||||
|
|
||||||
|
// Boutons et actions globales
|
||||||
|
'actions' => 'Accions',
|
||||||
|
'edit' => 'Modificar',
|
||||||
|
'delete' => 'Suprimir',
|
||||||
|
'save' => 'Desar',
|
||||||
|
'cancel' => 'Cancel·lar',
|
||||||
|
'btn_save' => 'Guardar',
|
||||||
|
'btn_cancel' => 'Cancel·lar',
|
||||||
|
'btn_delete' => 'Eliminar',
|
||||||
|
'btn_close' => 'Tancar',
|
||||||
|
'btn_back' => '◀ Tornar',
|
||||||
|
'btn_edit_bases' => '⚙️ Modificar les bases',
|
||||||
|
'btn_edit' => 'Modificar',
|
||||||
|
'error_occured' => 'S\'ha produït un error.',
|
||||||
|
|
||||||
|
// Mots génériques & Erreurs
|
||||||
|
'error' => 'Error.',
|
||||||
|
'confirm_delete' => 'Estàs segur que vols suprimir aquest element?',
|
||||||
|
|
||||||
|
// Mois & Jours
|
||||||
|
'month_01' => 'Gener', 'month_02' => 'Febrer', 'month_03' => 'Març',
|
||||||
|
'month_04' => 'Abril', 'month_05' => 'Maig', 'month_06' => 'Juny',
|
||||||
|
'month_07' => 'Juliol','month_08' => 'Agost', 'month_09' => 'Setembre',
|
||||||
|
'month_10' => 'Octubre','month_11' => 'Novembre','month_12' => 'Desembre',
|
||||||
|
'day_mon' => 'Dl', 'day_tue' => 'Dt', 'day_wed' => 'Dc',
|
||||||
|
'day_thu' => 'Dj', 'day_fri' => 'Dv', 'day_sat' => 'Ds', 'day_sun' => 'Dg',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// AUTHENTIFICATION / LOGIN
|
||||||
|
// ==========================================
|
||||||
|
'login_title' => 'Connexió - HouseHub',
|
||||||
|
'login_header' => 'Connexió',
|
||||||
|
'label_username' => "Nom d'usuari",
|
||||||
|
'label_password' => 'Contrasenya',
|
||||||
|
'placeholder_username' => 'ex: pacha',
|
||||||
|
'btn_login' => 'Connexió',
|
||||||
|
'btn_login_submit' => 'Connectar-se',
|
||||||
|
'logout' => 'Tancar sessió',
|
||||||
|
'btn_logout' => 'Tancar sessió',
|
||||||
|
'error_missing_fields' => 'Camps obligatoris.',
|
||||||
|
'error_invalid_credentials' => 'Identificadors incorrectes.',
|
||||||
|
'login_subtitle' => 'Si us plau, identifiqueu-vos per continuar',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ACCUEIL / INDEX
|
||||||
|
// ==========================================
|
||||||
|
'home_title' => 'HouseHub - Inici',
|
||||||
|
'home_welcome' => 'Benvinguts a HouseHub',
|
||||||
|
'home_subtitle' => "Centre de control de l'organització familiar.",
|
||||||
|
'home_logged_as' => 'Connectat com a',
|
||||||
|
'home_modules_title' => 'Mòduls',
|
||||||
|
'mod_calendar_name' => 'Calendari Familiar',
|
||||||
|
'mod_calendar_desc' => "Gestioneu les vacances, les modalitats de cura i l'organització setmanal.",
|
||||||
|
'mod_holidays_name' => 'Vacances',
|
||||||
|
'mod_holidays_desc' => 'Planifiqueu les vostres properes escapades, destinacions i idees de viatge.',
|
||||||
|
'mod_gifts_name' => 'Regals',
|
||||||
|
'mod_gifts_desc' => "Planifiqueu els regals per a cada infant, per Nadal i els aniversaris.",
|
||||||
|
'mod_budget_name' => 'Pressupost',
|
||||||
|
'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.',
|
||||||
|
'mod_garage_name' => 'Garatge',
|
||||||
|
'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.',
|
||||||
|
'mod_memo_name' => 'Notes',
|
||||||
|
'mod_memo_desc' => 'Els vostres memos, receptes, instruccions i informació diversa amb Markdown.',
|
||||||
|
'menu_memo' => 'Notes',
|
||||||
|
'mod_todo_name' => 'Todo',
|
||||||
|
'mod_todo_desc' => 'Gestioneu les vostres tasques, llistes de la compra i recordatoris familiars.',
|
||||||
|
'mod_calendar_ios_name' => 'Calendari iOS',
|
||||||
|
'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).',
|
||||||
|
'menu_todo' => 'Todo',
|
||||||
|
'mod_groceries_name' => 'Compra',
|
||||||
|
'mod_groceries_desc' => 'Llista compartida: afegiu productes i marqueu-los quan siguin al carretó.',
|
||||||
|
'menu_groceries' => 'Compra',
|
||||||
|
'groceries_placeholder_add' => 'Ex : llet, pa, tomàquets…',
|
||||||
|
'groceries_btn_add' => 'Afegir',
|
||||||
|
'groceries_btn_uncheck_all' => 'Tornar a marcar com a pendents',
|
||||||
|
'groceries_btn_delete_picked' => 'Eliminar els marcats',
|
||||||
|
'groceries_subtitle' => '{pending} pendents · {cart} al carretó',
|
||||||
|
'groceries_added' => 'Afegit',
|
||||||
|
'groceries_updated' => 'Actualitzat',
|
||||||
|
'groceries_deleted' => 'Eliminat',
|
||||||
|
'groceries_reset_next' => 'Llista llesta per a la propera compra',
|
||||||
|
'groceries_picked_removed' => 'Articles eliminats',
|
||||||
|
'groceries_label_empty' => 'El text no pot ser buit',
|
||||||
|
'groceries_confirm_del_title' => 'Eliminar?',
|
||||||
|
'groceries_confirm_del_body' => 'Voleu treure aquest producte de la llista?',
|
||||||
|
'groceries_confirm_uncheck_title' => 'Tornar a marcar com a pendents',
|
||||||
|
'groceries_confirm_uncheck_body' => 'Es netejaran les marques del carretó. Els productes es mantindran a la llista.',
|
||||||
|
'groceries_confirm_clear_title' => 'Eliminar els marcats',
|
||||||
|
'groceries_confirm_clear_body' => 'Tots els articles marcats se suprimiran de la llista.',
|
||||||
|
'groceries_empty' => 'Encara no hi ha res. Afegiu un producte a dalt.',
|
||||||
|
'groceries_aria_toggle' => 'Marcar com al carretó o pendent',
|
||||||
|
'groceries_section_pending' => 'Pendents',
|
||||||
|
'groceries_section_cart' => 'Al carretó',
|
||||||
|
'groceries_prompt_edit' => 'Editar el producte',
|
||||||
|
'groceries_btn_clear_list' => 'Buidar la llista',
|
||||||
|
'groceries_confirm_empty_list_title' => 'Buidar tota la llista?',
|
||||||
|
'groceries_confirm_empty_list_body' => 'Es trauran tots els productes. Romandran a l’historial per tornar-los a afegir ràpidament.',
|
||||||
|
'groceries_list_cleared' => 'Llista buidada',
|
||||||
|
'groceries_history_title' => 'Historial — un clic per afegir',
|
||||||
|
'groceries_history_empty' => 'L’historial es va omplint amb els productes que afegiu (fins al límit definit als paràmetres).',
|
||||||
|
'groceries_history_meta' => 'Fins a {max} productes · ',
|
||||||
|
'groceries_settings_link' => 'Paràmetres',
|
||||||
|
'groceries_settings_title' => 'Compra — historial',
|
||||||
|
'groceries_settings_intro' => 'Nombre màxim de productes recordats a l’historial (sense duplicats, sense distingir majúscules).',
|
||||||
|
'groceries_settings_history_max' => 'Mida de l’historial',
|
||||||
|
'groceries_settings_history_hint' => 'Entre 1 i 50. Les entrades més antigues se suprimeixen quan es supera el límit.',
|
||||||
|
'groceries_settings_saved' => 'Preferències de la compra desades.',
|
||||||
|
'groceries_settings_error' => 'No s’ha pogut desar:',
|
||||||
|
'groceries_err_duplicate' => 'Aquest producte ja és a la llista.',
|
||||||
|
'cta_open' => 'Obrir',
|
||||||
|
'cta_explore' => 'Explorar',
|
||||||
|
'cta_view_lists' => 'Desembolicar',
|
||||||
|
'cta_manage' => 'Gestionar',
|
||||||
|
'cta_service' => 'Revisar',
|
||||||
|
'cta_jot' => 'Apuntar',
|
||||||
|
'cta_check' => 'Marcar',
|
||||||
|
'cta_cart' => 'Anar a comprar',
|
||||||
|
'cta_sync' => 'Sincronitzar',
|
||||||
|
'mod_printvault_name' => 'PrintVault',
|
||||||
|
'mod_printvault_desc' => 'Gestioneu els vostres fitxers d\'impressió 3D — STL, 3MF, GCode.',
|
||||||
|
'menu_printvault' => 'PrintVault',
|
||||||
|
'cta_print' => 'Imprimir',
|
||||||
|
'mod_planka_name' => 'Planka',
|
||||||
|
'mod_planka_desc' => 'Gestioneu els vostres projectes en mode Kanban amb Planka.',
|
||||||
|
'menu_planka' => 'Planka',
|
||||||
|
|
||||||
|
// --- SETTINGS : ENFANTS ---
|
||||||
|
'set_kids_title' => 'Nens de la llar',
|
||||||
|
'set_kids_desc' => 'Gestioneu la llista de nens per associar-los a l\'estalvi, als regals, etc.',
|
||||||
|
'set_add_kid_placeholder' => 'Nom del nen',
|
||||||
|
'set_btn_add_kid' => 'Afegir',
|
||||||
|
'set_err_kid_name' => 'El nom és obligatori.',
|
||||||
|
'set_confirm_del_kid' => 'Vols eliminar aquest nen? (El seu historial financer es conservarà als arxius).',
|
||||||
|
'set_no_kids' => 'Cap nen configurat per a aquesta llar.',
|
||||||
|
'set_family_config' => 'Configuració de la llar',
|
||||||
|
'set_global_params' => 'Paràmetres globals',
|
||||||
|
'set_care_modes' => 'Modalitats de cura habituals',
|
||||||
|
'set_add_care_mode' => 'Afegir una modalitat',
|
||||||
|
'btn_save' => 'Desar',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// FAMILY CALENDAR
|
||||||
|
// ==========================================
|
||||||
|
'fc_page_title' => 'HouseHub - Calendari',
|
||||||
|
'fc_main_header' => 'Calendari Familiar',
|
||||||
|
'fc_btn_correct_balances' => 'Corregir els saldos',
|
||||||
|
'fc_btn_school_holidays' => 'Vacances escolars',
|
||||||
|
'fc_modal_holidays_title' => 'Vacances Escolars',
|
||||||
|
'fc_modal_snap_title' => 'Ajustar un saldo',
|
||||||
|
'fc_col_period' => 'Període',
|
||||||
|
'fc_col_from' => 'Des del',
|
||||||
|
'fc_col_to' => 'Fins al',
|
||||||
|
'fc_label_person' => 'Persona',
|
||||||
|
'fc_label_leave_type' => 'Tipus de permís',
|
||||||
|
'fc_label_apply_date' => "Data d'aplicació",
|
||||||
|
'fc_snap_date_hint' => "Trieu preferiblement el primer dia d'un mes",
|
||||||
|
'fc_label_remaining_balance'=> 'Saldo restant en aquesta data',
|
||||||
|
'fc_placeholder_balance' => 'ex: 12.5',
|
||||||
|
'fc_btn_save_snap' => 'Desar el correctiu',
|
||||||
|
'fc_view_1m' => '1 mes',
|
||||||
|
'fc_view_2m' => '2 mesos',
|
||||||
|
'fc_view_3m' => '3 mesos',
|
||||||
|
'fc_view_year' => 'Any',
|
||||||
|
'fc_weekly_planning' => 'Planificació setmanal',
|
||||||
|
'fc_col_month' => 'Mes',
|
||||||
|
'fc_col_week_short' => 'Set.',
|
||||||
|
'fc_col_av' => 'Av.',
|
||||||
|
'fc_col_use' => 'Use',
|
||||||
|
'fc_legend_title' => 'Llegenda',
|
||||||
|
'fc_summary_title' => 'Resum',
|
||||||
|
'fc_summ_year' => 'Any',
|
||||||
|
'fc_summ_month' => 'Mes',
|
||||||
|
'fc_school_year' => 'Curs escolar',
|
||||||
|
'fc_unit_days' => 'dies',
|
||||||
|
'fc_menu_carole' => 'Permisos Carole',
|
||||||
|
'fc_clear' => 'Esborrar',
|
||||||
|
'fc_menu_kids_leaves' => 'Permisos nens',
|
||||||
|
'fc_err_save_snap' => 'Error en desar el correctiu.',
|
||||||
|
'fc_err_fetch_gov' => 'Error en la connexió amb l\'API governamental.',
|
||||||
|
'fc_err_no_data_gov' => 'No s\'han trobat dades a l\'API del govern per a aquest any.',
|
||||||
|
'fc_err_action' => 'Error en l\'acció: ',
|
||||||
|
'fc_today_short' => 'Avui',
|
||||||
|
'fc_today_title' => 'Anar a avui',
|
||||||
|
'fc_tab_foyer' => '🏡 Llar',
|
||||||
|
'fc_tab_members' => '👥 Membres i Drets',
|
||||||
|
'fc_select_member' => 'Selecciona un membre de la família:',
|
||||||
|
'fc_role_adult' => 'Adult',
|
||||||
|
'fc_role_child' => 'Nen',
|
||||||
|
'fc_care_modes_title' => '👶 Modes de cura associats',
|
||||||
|
'fc_care_modes_desc' => 'Marca els modes de cura que s\'apliquen a aquest nen.',
|
||||||
|
'fc_no_care_mode' => 'Cap mode configurat.',
|
||||||
|
'fc_matrix_title' => '👨👩👦 Matriu de Drets de Permís',
|
||||||
|
'fc_matrix_desc' => 'Declara els codis de comptadors i les dates de renovació.',
|
||||||
|
'fc_matrix_code' => 'Codi',
|
||||||
|
'fc_matrix_date' => 'Data de Reset',
|
||||||
|
'fc_matrix_empty' => 'Cap comptador configurat.',
|
||||||
|
'fc_matrix_saved' => 'Matriu de drets actualitzada! 💼',
|
||||||
|
'fc_child_saved' => 'Subscripció del nen desada!',
|
||||||
|
'btn_add_counter' => '➕ Afegir',
|
||||||
|
'btn_save_rights' => '💾 Desar',
|
||||||
|
'fc_modal_settings_title' => 'Configuració del Calendari',
|
||||||
|
'fc_zone_label' => 'Zona Escolar (Vacances)',
|
||||||
|
'fc_zone_desc' => 'Determina el calendari automàtic de vacances escolars.',
|
||||||
|
'fc_care_modes_label' => 'Modes de Cura de la Llar',
|
||||||
|
'fc_care_modes_desc' => 'Gestiona la llista global de modes de cura possibles per a la família.',
|
||||||
|
'fc_add_mode_placeholder' => 'Ex: Mainadera, Casal, Avis...',
|
||||||
|
'fc_settings_updated' => 'Configuració actualitzada correctament!',
|
||||||
|
'fc_first_day' => 'Primer dia de la setmana',
|
||||||
|
// --- CALENDARI : PARÀMETRES (MEMBRES I VISUALITZACIÓ) ---
|
||||||
|
'fc_tab_display' => '🖥️ Visualització',
|
||||||
|
'fc_display_title' => 'Ajustos de visualització',
|
||||||
|
'fc_display_desc' => 'Personalitza l\'aparença i el comportament predeterminat del teu calendari.',
|
||||||
|
'fc_default_view' => 'Vista per defecte',
|
||||||
|
'fc_view_month' => '1 mes',
|
||||||
|
'fc_view_2months' => '2 mesos',
|
||||||
|
'fc_view_3months' => '3 mesos',
|
||||||
|
'fc_working_hours' => 'Horari de cura (Per defecte)',
|
||||||
|
'fc_working_hours_desc' => 'Exemple: 08:30-18:00',
|
||||||
|
|
||||||
|
'fc_role_unknown' => 'Desconegut',
|
||||||
|
'fc_role_adult' => 'Adult',
|
||||||
|
'fc_role_child' => 'Nen/a',
|
||||||
|
'fc_role_helper' => 'Ajudant',
|
||||||
|
'fc_error' => 'Error: ',
|
||||||
|
|
||||||
|
'fc_care_modes_title' => 'Modes de Cura Predeterminats',
|
||||||
|
'fc_care_modes_desc' => 'Quins modes de cura s\'apliquen a aquest nen/a?',
|
||||||
|
'fc_no_care_mode' => 'No hi ha modes de cura configurats a la llar.',
|
||||||
|
'fc_child_saved' => 'Desat!',
|
||||||
|
|
||||||
|
'fc_matrix_title' => 'Matriu de Permisos',
|
||||||
|
'fc_matrix_desc' => 'Defineix les quotes i dates de renovació.',
|
||||||
|
'fc_matrix_empty' => 'No hi ha comptadors definits.',
|
||||||
|
'fc_matrix_saved' => 'Matriu desada!',
|
||||||
|
|
||||||
|
'fc_leave_obsolete' => 'Obsolet',
|
||||||
|
'fc_leave_code_placeholder' => 'Codi...',
|
||||||
|
'fc_leave_method_fixed' => 'Fix',
|
||||||
|
'fc_leave_method_accumulated' => 'Acumulat',
|
||||||
|
|
||||||
|
'fc_date_format_placeholder' => 'DD/MM',
|
||||||
|
'fc_date_format_title' => 'Format DD/MM',
|
||||||
|
|
||||||
|
'fc_table_code' => 'Codi',
|
||||||
|
'fc_table_method' => 'Mètode',
|
||||||
|
'fc_table_quota' => 'Quota',
|
||||||
|
'fc_table_renewal' => 'Renov.',
|
||||||
|
|
||||||
|
'btn_save_rights' => 'Guardar',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MODAL DE CONFIGURACIÓ DEL CALENDARI
|
||||||
|
// ==========================================
|
||||||
|
'fc_modal_settings_title' => 'Configuració del calendari',
|
||||||
|
'fc_tab_foyer' => 'Llar',
|
||||||
|
'fc_tab_members' => 'Membres i Drets',
|
||||||
|
|
||||||
|
// Llar: Zona escolar
|
||||||
|
'fc_zone_label' => 'Zona escolar',
|
||||||
|
'fc_zone_a' => 'Zona A',
|
||||||
|
'fc_zone_b' => 'Zona B',
|
||||||
|
'fc_zone_c' => 'Zona C',
|
||||||
|
'fc_zone_other' => 'Altra / Fora de França',
|
||||||
|
'fc_zone_desc' => 'Permet mostrar correctament les vacances escolars.',
|
||||||
|
|
||||||
|
// Llar: Opcions de cura (Modes de garde)
|
||||||
|
'fc_care_modes_label' => 'Modalitats de cura',
|
||||||
|
'fc_add_mode_placeholder' => 'Ex: Mainadera, Llar d\'infants...',
|
||||||
|
'fc_care_modes_desc' => 'Afegeix les diferents modalitats de cura per als teus fills.',
|
||||||
|
'fc_empty_kids_msg' => 'Actualment no hi ha cap infant configurat a la teva família.',
|
||||||
|
'fc_link_settings' => 'Anar a la configuració general',
|
||||||
|
|
||||||
|
// Llar: Catàleg de permisos (Congés)
|
||||||
|
'fc_leave_catalog_title' => 'Catàleg de permisos',
|
||||||
|
'fc_leave_catalog_desc' => 'Defineix els tipus de permisos possibles per a tota la família.',
|
||||||
|
'fc_add_leave_type' => 'Afegir un tipus de permís',
|
||||||
|
'fc_col_code' => 'Codi',
|
||||||
|
'fc_code_irreversible' => 'Irreversible',
|
||||||
|
'fc_col_label' => 'Etiqueta',
|
||||||
|
'fc_leave_label_ph' => 'Ex: Vacances, Assumptes propis...',
|
||||||
|
'fc_quota_default' => 'Quota per defecte',
|
||||||
|
'fc_reset_month' => 'Mes de renovació',
|
||||||
|
|
||||||
|
// Membres
|
||||||
|
'fc_select_member' => 'Selecciona un membre:',
|
||||||
|
|
||||||
|
// Mesos
|
||||||
|
'month_jan' => 'Gener',
|
||||||
|
'month_feb' => 'Febrer',
|
||||||
|
'month_mar' => 'Març',
|
||||||
|
'month_apr' => 'Abril',
|
||||||
|
'month_may' => 'Maig',
|
||||||
|
'month_jun' => 'Juny',
|
||||||
|
'month_jul' => 'Juliol',
|
||||||
|
'month_aug' => 'Agost',
|
||||||
|
'month_sep' => 'Setembre',
|
||||||
|
'month_oct' => 'Octubre',
|
||||||
|
'month_nov' => 'Novembre',
|
||||||
|
'month_dec' => 'Desembre',
|
||||||
|
|
||||||
|
'leg_presence' => 'Presència Pep',
|
||||||
|
'leg_school_holidays' => 'Vacances Escolars',
|
||||||
|
'leg_public_holiday' => 'Festiu',
|
||||||
|
'leg_off_carole' => 'Off Carole',
|
||||||
|
'leg_extra_off' => 'Extra Off',
|
||||||
|
'leg_centre' => 'Casal',
|
||||||
|
'leg_avis' => 'Avís',
|
||||||
|
'leg_pep_sick' => 'Pep Malalt',
|
||||||
|
'care_nounou' => 'Kangur',
|
||||||
|
'care_centre' => 'Casal',
|
||||||
|
'care_avis' => 'Avis',
|
||||||
|
|
||||||
|
'vac_toussaint' => 'Vacances de Tots Sants',
|
||||||
|
'vac_noel' => 'Vacances de Nadal',
|
||||||
|
'vac_hiver' => "Vacances d'Hivern",
|
||||||
|
'vac_printemps' => 'Vacances de Primavera',
|
||||||
|
'vac_ascension' => "Pont de l'Ascensió",
|
||||||
|
'vac_ete' => "Vacances d'Estiu",
|
||||||
|
'btn_off' => 'Off',
|
||||||
|
'btn_extra' => 'Extra',
|
||||||
|
|
||||||
|
'fc_alert_burn_days' => '⚠️ Risc de pèrdua : %s d a gastar abans del %s',
|
||||||
|
'fc_alert_burn_jra' => '⚠️ JRA: Només 2d es poden reportar després de febrer!',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HOLIDAYS (VOYAGES)
|
||||||
|
// ==========================================
|
||||||
|
'hdl_title_list' => 'HouseHub - Les meves vacances',
|
||||||
|
'hdl_title_detail' => 'HouseHub - Detall del viatge',
|
||||||
|
'hdl_main_title' => 'Les meves vacances',
|
||||||
|
'hdl_filter_all' => 'Mostrar-ho tot',
|
||||||
|
'hdl_filter_year' => 'Any',
|
||||||
|
'hdl_left_to_pay_label' => 'Resta per pagar',
|
||||||
|
'hdl_btn_create' => 'Crear un viatge',
|
||||||
|
'hdl_no_active_trips' => 'Cap viatge en curs per aquest període. Planifiquem alguna cosa!',
|
||||||
|
'hdl_history_title' => 'Historial',
|
||||||
|
'hdl_dates_to_define' => 'Dates per definir',
|
||||||
|
'hdl_total_budget' => 'Pressupost Total',
|
||||||
|
'hdl_paid' => 'Pagat',
|
||||||
|
'hdl_saved' => 'Finançat',
|
||||||
|
'hdl_left_to_pay' => 'Resta per pagar',
|
||||||
|
'hdl_status_booked' => 'Confirmat',
|
||||||
|
'hdl_status_planned' => 'Planificat',
|
||||||
|
'hdl_status_draft' => 'Esborrany',
|
||||||
|
'hdl_status_passed' => 'Passat',
|
||||||
|
'hdl_status_archived' => 'Arxivat',
|
||||||
|
'hdl_modal_title' => 'Planificar el viatge',
|
||||||
|
'hdl_label_name' => 'Nom del viatge',
|
||||||
|
'hdl_ph_name' => 'Ex: Octubre - Portugal',
|
||||||
|
'hdl_label_status' => 'Estat',
|
||||||
|
'hdl_label_period' => 'Període (Text lliure)',
|
||||||
|
'hdl_ph_period' => 'Ex: Octubre 2026',
|
||||||
|
'hdl_label_from' => 'Des del',
|
||||||
|
'hdl_label_to' => 'Fins al',
|
||||||
|
'hdl_cat_transport' => 'Transport',
|
||||||
|
'hdl_add_transport' => 'Afegir un transport',
|
||||||
|
'hdl_cat_accommodation' => 'Allotjament',
|
||||||
|
'hdl_add_accommodation' => 'Afegir un allotjament',
|
||||||
|
'hdl_cat_activity' => 'Activitat',
|
||||||
|
'hdl_add_activity' => 'Afegir una activitat',
|
||||||
|
'hdl_label_budget_food' => 'Pressupost Menjar i Beguda (€)',
|
||||||
|
'hdl_label_budget_extras' => 'Pressupost Extras (€)',
|
||||||
|
'hdl_label_notes' => 'Notes',
|
||||||
|
'hdl_ph_notes' => 'Idees soltes...',
|
||||||
|
'hdl_label_budget_food_extras' => 'Pressupost Menjar i Extres',
|
||||||
|
'hdl_map_itinerary' => 'Itinerari',
|
||||||
|
'hdl_outbound' => 'Anada',
|
||||||
|
'hdl_return' => 'Tornada',
|
||||||
|
'hdl_btn_add_step' => 'Afegir una etapa',
|
||||||
|
'hdl_step_details' => 'Detalls del roadtrip',
|
||||||
|
'hdl_no_steps' => 'Cap etapa definida. Comenceu per afegir-ne una!',
|
||||||
|
'hdl_general_costs' => 'Despeses generals i Pressupostos',
|
||||||
|
'hdl_food_bev' => 'Menjar i Beguda',
|
||||||
|
'hdl_extras' => 'Extres',
|
||||||
|
'hdl_view_planning' => 'Veure la planificació',
|
||||||
|
'hdl_passing_point' => 'Punt de pas (sense despeses)',
|
||||||
|
'hdl_search_location' => 'Cercar un lloc',
|
||||||
|
'hdl_ph_search' => 'Ciutat, hotel, adreça...',
|
||||||
|
'hdl_label_step_name' => "Nom de l'etapa",
|
||||||
|
'hdl_label_arrival' => 'Arribada',
|
||||||
|
'hdl_label_departure' => 'Sortida',
|
||||||
|
'hdl_planned_expenses' => 'Despeses previstes per a aquesta etapa',
|
||||||
|
'hdl_btn_add_expense' => '+ Afegir una despesa',
|
||||||
|
'hdl_save_fav' => 'Desar als meus preferits',
|
||||||
|
'hdl_planning_title' => 'Planificació de l\'etapa',
|
||||||
|
'hdl_to_pay' => 'Per pagar',
|
||||||
|
'hdl_js_search_loading' => 'Cerca en curs...',
|
||||||
|
'hdl_js_no_result' => 'No s\'han trobat resultats.',
|
||||||
|
'hdl_js_network_error' => 'Error de connexió al servei de mapes.',
|
||||||
|
'hdl_js_confirm_del_trip' => 'Estàs segur que vols suprimir aquest viatge sencer?',
|
||||||
|
'hdl_js_confirm_del_step' => 'Suprimir aquesta etapa?',
|
||||||
|
'hdl_js_step_label' => 'Etapa',
|
||||||
|
'hdl_js_ph_expense_name' => 'Ex: Hotel, Peatge...',
|
||||||
|
'hdl_quick_edit_title' => 'Modificació ràpida',
|
||||||
|
'hdl_js_edit_step' => 'Modificar l\'etapa',
|
||||||
|
'hdl_js_missing_dates_title'=> 'Dates mancants',
|
||||||
|
'hdl_js_missing_dates_msg' => 'Cal definir una data d\'arribada i sortida per organitzar la planificació.',
|
||||||
|
'hdl_to_place' => 'Per situar',
|
||||||
|
'hdl_from' => 'Des del',
|
||||||
|
'hdl_to' => 'Fins al',
|
||||||
|
'hdl_btn_add_step' => 'Afegir una etapa',
|
||||||
|
'hdl_quick_edit_title' => 'Modificació ràpida',
|
||||||
|
'hdl_paid' => 'Pagat',
|
||||||
|
'weather_forecast' => 'Previsions meteorològiques',
|
||||||
|
'weather_not_available' => 'Temps no disponible',
|
||||||
|
'temp_max' => 'Temp. Màxima',
|
||||||
|
'weather_loading' => 'Carregant el temps...',
|
||||||
|
'weather_error' => 'Temps no disponible',
|
||||||
|
'weather_sunny' => 'Assolellat',
|
||||||
|
'weather_cloudy' => 'Ennuvolat',
|
||||||
|
'weather_rainy' => 'Plujós',
|
||||||
|
'weather_snowy' => 'Nevat',
|
||||||
|
'weather_historical' => 'Basat en l\'històric (estimació)',
|
||||||
|
'err_invalid_holiday_id' => 'ID de viatge no vàlid.',
|
||||||
|
'hdl_default_exp_name' => 'Despesa',
|
||||||
|
'hdl_select_beneficiary' => '-- Seleccionar --',
|
||||||
|
|
||||||
|
// Anciennes clés Holidays non préfixées (conservades)
|
||||||
|
'outbound_trip' => 'Anada',
|
||||||
|
'return_trip' => 'Tornada',
|
||||||
|
'period' => 'Període',
|
||||||
|
'budget_food' => 'Pressupost Menjar i Extres',
|
||||||
|
'total_cost' => 'Cost Total Estimat',
|
||||||
|
'paid' => 'Pagat',
|
||||||
|
'provisioned' => 'Provisionat',
|
||||||
|
'left_to_pay' => 'Pendent de pagament',
|
||||||
|
'map_itinerary' => '🗺️ Itinerari',
|
||||||
|
'place_step' => '📍 Afegir una etapa',
|
||||||
|
'step_details' => '📝 Detall de les etapes',
|
||||||
|
'no_steps' => 'Cap etapa planificada.',
|
||||||
|
'travel_notes' => '📝 Notes del viatge',
|
||||||
|
'to_be_defined' => 'A definir',
|
||||||
|
'funded' => 'Finançat (Provisió)',
|
||||||
|
'view_planning' => 'Veure el planning',
|
||||||
|
'to_pay' => 'A pagar',
|
||||||
|
'from_date' => 'Del',
|
||||||
|
'to_date' => 'al',
|
||||||
|
'passing_point' => 'Punt de pas (Cap despesa)',
|
||||||
|
'search_location' => 'Cercar una ubicació geogràfica',
|
||||||
|
'search_placeholder' => 'Ex: París, Ibis Barcelona...',
|
||||||
|
'step_name' => 'Nom de l\'etapa',
|
||||||
|
'arrival_optional' => 'Arribada el (Opcional)',
|
||||||
|
'departure_optional' => 'Sortida el (Opcional)',
|
||||||
|
'planned_expenses' => 'Despeses previstes',
|
||||||
|
'add_expense' => '+ Afegir una despesa',
|
||||||
|
'save_favorite' => 'Guardar als meus preferits',
|
||||||
|
'planning_title' => 'Planning',
|
||||||
|
'modal_plan_trip' => 'Planificar el viatge',
|
||||||
|
'modal_quick_edit' => 'Edició ràpida',
|
||||||
|
'err_modal_missing' => 'Error: La modal holidayModal no es troba.',
|
||||||
|
'placeholder_title' => 'Títol',
|
||||||
|
'placeholder_price' => 'Preu (€)',
|
||||||
|
'already_paid' => 'Ja pagat?',
|
||||||
|
'remove_line' => 'Treure aquesta línia',
|
||||||
|
'confirm_delete_trip' => 'Vols eliminar definitivament aquest viatge? Aquesta acció és irreversible.',
|
||||||
|
'step_number' => 'Etapa',
|
||||||
|
'place_new_step' => '📍 Afegir una nova etapa',
|
||||||
|
'edit_step' => '✏️ Modificar l\'etapa',
|
||||||
|
'search_in_progress' => 'Cerca en curs... ⏳',
|
||||||
|
'no_result_found' => 'Cap resultat trobat.',
|
||||||
|
'network_error' => 'Error de xarxa.',
|
||||||
|
'placeholder_label' => 'Etiqueta',
|
||||||
|
'placeholder_notes_link' => '🔗 Enllaç de reserva o notes (Opcional)...',
|
||||||
|
'confirm_delete_step' => 'Eliminar aquesta etapa i totes les despeses associades?',
|
||||||
|
'planning_of' => '📅 Planning : ',
|
||||||
|
'missing_dates_title' => '⚠️ Dates que falten',
|
||||||
|
'missing_dates_msg' => 'Si us plau, modifica aquesta etapa (✏️) i omple la seva <strong>Data d\'arribada i de sortida</strong>.',
|
||||||
|
'to_place' => '📥 A col·locar',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BUDGET (GLOBAL & CATEGORIES)
|
||||||
|
// ==========================================
|
||||||
|
'budget_page_title' => 'HouseHub - Pressupost',
|
||||||
|
'budget_main_header' => 'Gestió del Pressupost',
|
||||||
|
'budget_tab_tracking' => 'Seguiment Mensual',
|
||||||
|
'budget_tab_prev' => 'Pressupost 2026',
|
||||||
|
'budget_tab_savings' => 'Estalvis',
|
||||||
|
'budget_tab_recap' => 'Resum',
|
||||||
|
'budget_err_file_not_found' => 'Vaja! Fitxer no trobat.',
|
||||||
|
'budget_err_file_detail' => 'Comproveu que el fitxer views/%s.php existeix.',
|
||||||
|
'budget_err_not_authorized' => 'Pàgina no autoritzada.',
|
||||||
|
'cat_income' => 'Ingressos',
|
||||||
|
'cat_fmcg' => 'Compres (FMCG)',
|
||||||
|
'cat_fuel' => 'Benzina',
|
||||||
|
'cat_school' => 'Escola / Llar d\'infants',
|
||||||
|
'cat_fixed' => 'Despeses Fixes',
|
||||||
|
'cat_others' => 'Altres / Imprevistos',
|
||||||
|
'cat_savings' => 'Estalvis',
|
||||||
|
'budget_tab_kids' => 'Nens',
|
||||||
|
'budget_settings_title' => 'Configuració del Pressupost',
|
||||||
|
'bs_tab_accounts' => '💳 Comptes',
|
||||||
|
'bs_tab_categories' => '🏷️ Categories',
|
||||||
|
'bs_tab_rules' => '🤖 Regles d\'importació',
|
||||||
|
'bs_tab_salaries' => '💼 Salaris',
|
||||||
|
'bs_empty_accounts' => 'Cap compte configurat.',
|
||||||
|
'bs_type_checking' => 'Corrent',
|
||||||
|
'bs_type_savings' => 'Estalvi',
|
||||||
|
'bs_badge_default' => 'Per defecte',
|
||||||
|
'bs_owner' => 'Propietari',
|
||||||
|
'bs_shared_account' => 'Compte conjunt',
|
||||||
|
'bs_empty_categories' => 'Cap categoria configurada.',
|
||||||
|
'bs_type_income' => 'Ingrés',
|
||||||
|
'bs_type_expense' => 'Despesa',
|
||||||
|
'bs_type_savings_cat' => 'Estalvi',
|
||||||
|
'bs_empty_rules' => 'Cap regla d\'importació.',
|
||||||
|
'bs_empty_salaries' => 'Cap salari definit per a l\'any',
|
||||||
|
'bs_salaries_year_desc' => 'Configuracions per a l\'any',
|
||||||
|
'bs_salary_net' => 'Salari net',
|
||||||
|
'bs_salary_fees' => 'Mensualitat / Despeses',
|
||||||
|
'bs_add_account_title' => 'Afegir un compte',
|
||||||
|
'bs_account_name_ph' => 'Nom (ex: Estalvi Bebè)',
|
||||||
|
'bs_confirm_delete' => 'Segur que voleu suprimir aquest compte?',
|
||||||
|
'bs_add_category_title' => '+ Afegir una categoria',
|
||||||
|
'bs_cat_code_ph' => 'Codi (ex: FOOD)',
|
||||||
|
'bs_cat_label_ph' => 'Etiqueta (ex: Compres)',
|
||||||
|
'bs_cat_icon_ph' => 'Icona (ex: 🛒)',
|
||||||
|
'bs_confirm_delete_cat' => 'Segur que voleu suprimir aquesta categoria?',
|
||||||
|
'bs_add_rule_title' => 'Afegir una regla',
|
||||||
|
'bs_rule_keyword_ph' => 'Paraula clau (ex: AUCHAN)',
|
||||||
|
'bs_confirm_delete_rule'=> 'Segur que voleu suprimir aquesta regla?',
|
||||||
|
'bs_currency_label' => 'Divisa de la llar',
|
||||||
|
'bs_currency_desc' => 'Divisa principal utilitzada per mostrar els vostres comptes i pressupostos.',
|
||||||
|
'bs_currency_updated' => 'Divisa actualitzada correctament!',
|
||||||
|
// ONGLET CSV MAPPING
|
||||||
|
'bs_csv_title' => 'Format de la importació CSV',
|
||||||
|
'bs_csv_desc' => 'Configureu aquí les columnes que corresponen a l\'exportació del vostre banc.',
|
||||||
|
'bs_csv_delimiter' => 'Separador',
|
||||||
|
'bs_csv_delim_semi' => 'Punt i coma (;)',
|
||||||
|
'bs_csv_delim_comma' => 'Coma (,)',
|
||||||
|
'bs_csv_delim_tab' => 'Tabulació',
|
||||||
|
'bs_csv_date_format' => 'Format de la Data',
|
||||||
|
'bs_csv_col_index' => 'Índex de les columnes',
|
||||||
|
'bs_csv_col_index_help' => '(Atenció: la 1a columna és la Número 0)',
|
||||||
|
'bs_csv_col_date' => 'Núm Col: Data',
|
||||||
|
'bs_csv_col_label' => 'Núm Col: Concepte / Motiu',
|
||||||
|
'bs_csv_amount_mgmt' => 'Gestió dels imports',
|
||||||
|
'bs_csv_amount_single' => 'Una sola columna (Positiu = Ingrés, Negatiu = Despesa)',
|
||||||
|
'bs_csv_amount_split' => 'Dues columnes separades (Dèbit / Crèdit)',
|
||||||
|
'bs_csv_col_amount' => 'Núm Col: Import',
|
||||||
|
'bs_csv_col_debit' => 'Núm Col: Dèbit',
|
||||||
|
'bs_csv_col_credit' => 'Núm Col: Crèdit',
|
||||||
|
'bs_csv_col_ref' => 'Núm Col: Referència única (Opcional)',
|
||||||
|
'btn_save_format' => 'Desar el format',
|
||||||
|
'bs_csv_saved' => 'Format CSV desat',
|
||||||
|
'bs_tab_csv' => '📥 Importació CSV',
|
||||||
|
'bs_csv_settings_title' => 'Configuració del format',
|
||||||
|
'bs_csv_drop_title' => 'Arrossegueu el vostre fitxer CSV aquí',
|
||||||
|
'bs_csv_drop_desc' => 'o feu clic per cercar fitxers',
|
||||||
|
'bs_csv_err_type' => 'Error: Només s\'admeten fitxers .csv.',
|
||||||
|
'bs_csv_file_ok' => 'Fitxer carregat',
|
||||||
|
'bs_csv_preview_title' => 'Previsualització de les vostres columnes',
|
||||||
|
'bs_csv_empty_col' => 'Buit',
|
||||||
|
'bs_csv_no_data' => 'No s\'ha trobat cap línia de transacció en aquest fitxer.',
|
||||||
|
|
||||||
|
// --- BUDGET : SUIVI ---
|
||||||
|
'bud_rem_school' => 'Escola (Resta estimada)',
|
||||||
|
'bud_rem_fmcg' => 'Compres (Resta estimada)',
|
||||||
|
'bud_rem_fuel' => 'Benzina (Resta estimada)',
|
||||||
|
'bud_closed_archived' => 'Aquest mes està tancat i arxivat.',
|
||||||
|
'bud_budget_of' => 'Pressupost:',
|
||||||
|
'bud_confirm_close' => 'Tancar aquest mes? Això congelarà els saldos actuals i canviarà el seguiment per defecte al mes vinent.',
|
||||||
|
'bud_close_btn' => 'Tancar',
|
||||||
|
'bud_confirm_reopen' => 'Reobrir aquest mes? Els seus saldos es recalcularan en directe.',
|
||||||
|
'bud_reopen_btn' => 'Reobrir',
|
||||||
|
'bud_active_month_btn' => 'Mes Actiu',
|
||||||
|
'bud_bank_balance' => 'Saldo bancari',
|
||||||
|
'bud_frozen' => 'Congelat',
|
||||||
|
'bud_current' => 'Actual',
|
||||||
|
'bud_update_btn' => 'Act.',
|
||||||
|
'bud_theoretical_balance' => 'Saldo teòric final de mes',
|
||||||
|
'bud_upcoming_charges' => 'Càrrecs pendents',
|
||||||
|
'bud_all_paid' => 'Tot està pagat!',
|
||||||
|
'bud_capacity_tooltip' => 'Càlcul: Report Mes Anterior (%s€) + Sous (%s€) + Altres ingressos (%s€)',
|
||||||
|
'bud_max_capacity' => 'Capacitat Màx',
|
||||||
|
'bud_actual' => 'Actual',
|
||||||
|
'bud_theoretical' => 'Teòric',
|
||||||
|
'bud_import_csv' => 'Importar CSV',
|
||||||
|
'bud_csv_file' => 'Fitxer CSV (Punt i coma)',
|
||||||
|
'bud_preview' => 'Previsualitzar',
|
||||||
|
'bud_validate_import' => 'Validar importació',
|
||||||
|
'bud_assign_month' => 'Assignar al mes',
|
||||||
|
'bud_op_date' => 'Data Operació',
|
||||||
|
'bud_label' => 'Concepte',
|
||||||
|
'bud_amount' => 'Import',
|
||||||
|
'bud_category' => 'Categoria',
|
||||||
|
'bud_ignore' => 'Ignorar',
|
||||||
|
'bud_to_define' => 'Per definir',
|
||||||
|
'bud_is_charge' => 'Despesa?',
|
||||||
|
'bud_is_income' => 'Ingrés?',
|
||||||
|
'btn_import' => 'Importar',
|
||||||
|
'bud_no_lines' => 'Cap línia.',
|
||||||
|
'bud_confirm_delete' => 'Suprimir aquesta línia?',
|
||||||
|
'bud_new_transaction' => 'Nova transacció',
|
||||||
|
'bud_assign_to_month' => 'Assignar al mes de gestió',
|
||||||
|
'bud_real_date' => 'Data real de l\'operació',
|
||||||
|
'bud_type' => 'Tipus',
|
||||||
|
'bud_type_expense' => 'Despesa',
|
||||||
|
'bud_type_income' => 'Ingrés / Reemborsament',
|
||||||
|
'bud_beneficiary' => 'Beneficiari',
|
||||||
|
'bud_fixed_charge' => 'Despesa Fixa',
|
||||||
|
'bud_expected_income' => 'Ingrés Esperat',
|
||||||
|
'bud_amount_eur' => 'Import (€)',
|
||||||
|
'bud_update_balance' => 'Act. Saldo Bancari',
|
||||||
|
'bud_balance_eur' => 'Saldo (€)',
|
||||||
|
'bud_add_title' => 'Afegir',
|
||||||
|
'bud_edit_title' => 'Modificar la transacció',
|
||||||
|
'bud_to_define_js' => 'per definir',
|
||||||
|
'bud_bar_actual' => "Actual",
|
||||||
|
'bud_bar_theoretical' => "Teòric",
|
||||||
|
'bud_bar_max_cap' => "Capacitat Màx.",
|
||||||
|
'bud_edit_title' => 'Modificar la transacció',
|
||||||
|
'bud_to_define_js' => 'per definir',
|
||||||
|
'bud_select_beneficiary' => 'Triar un ingrés esperat',
|
||||||
|
'bud_bar_actual' => 'Actual',
|
||||||
|
|
||||||
|
// ONBOARDING BUDGET
|
||||||
|
'bs_onb_title' => 'Benvinguts! Configurem el teu pressupost:',
|
||||||
|
'bs_onb_desc' => 'Completa aquests passos perquè l\'aplicació pugui calcular el teu pressupost disponible:',
|
||||||
|
'bs_onb_cat_link' => 'Categories',
|
||||||
|
'bs_onb_cat_desc' => 'Crea almenys una categoria de despeses.',
|
||||||
|
'bs_onb_acc_link' => 'Comptes Bancaris',
|
||||||
|
'bs_onb_acc_desc' => 'Afegeix almenys un compte corrent o d\'estalvi.',
|
||||||
|
'bs_onb_sal_link' => 'Sous',
|
||||||
|
'bs_onb_sal_desc' => 'Introdueix els ingressos de l\'any en curs.',
|
||||||
|
'bs_onb_items_link' => 'Línies de Pressupost',
|
||||||
|
'bs_onb_items_desc' => 'Ves a la pestanya Resum per llistar les teves despeses fixes (Lloguer, Assegurances...).',
|
||||||
|
'bs_rule_search_ph' => '🔍 Cercar una paraula clau (ex: AUCHAN)...',
|
||||||
|
'error_loading_settings' => 'Error en carregar la configuració',
|
||||||
|
'settings' => 'Configuració',
|
||||||
|
|
||||||
|
// --- MODULE BUDGET : IMPORT CSV ---
|
||||||
|
'bud_import_csv' => "Importar CSV",
|
||||||
|
'bud_csv_file' => "Seleccioneu el fitxer del vostre banc (.csv)",
|
||||||
|
'bud_preview' => "Previsualitzar",
|
||||||
|
'bud_validate_import' => "Si us plau, comproveu i categoritzeu les línies abans de validar.",
|
||||||
|
'bud_assign_month' => "Mes de gestió",
|
||||||
|
'bud_op_date' => "Data",
|
||||||
|
'bud_amount' => "Import",
|
||||||
|
'bud_ignore' => "Ignorar (Crèdit)",
|
||||||
|
'bud_to_define' => "Per definir",
|
||||||
|
'bud_is_charge' => "És un càrrec fix...",
|
||||||
|
'bud_is_income' => "És un ingrés...",
|
||||||
|
|
||||||
|
// --- BUDGET : RECAP ---
|
||||||
|
'bud_recap_monthly_title' => 'Resum Mensual',
|
||||||
|
'bud_recap_btn_add' => 'Afegir una despesa / ingrés',
|
||||||
|
'bud_planned_amount' => 'Import Previst',
|
||||||
|
'bud_day' => 'Dia',
|
||||||
|
'bud_month_state' => 'Estat del mes',
|
||||||
|
'bud_est_short' => 'Est.',
|
||||||
|
'bud_reg_planned_in' => 'Regularització prevista al %s',
|
||||||
|
'bud_bonus' => 'Bonificació',
|
||||||
|
'bud_overrun' => 'Excés',
|
||||||
|
'bud_remaining' => 'Resta',
|
||||||
|
'bud_exact_amount' => 'Import exacte',
|
||||||
|
'bud_per_month_short' => 'És a dir',
|
||||||
|
'bud_month_short' => 'mes',
|
||||||
|
'bud_freq_mensuel' => 'Mensual',
|
||||||
|
'bud_freq_annuel' => 'Anual',
|
||||||
|
'bud_state_validated' => 'Validat',
|
||||||
|
'bud_state_partial' => 'Parcial',
|
||||||
|
'bud_state_waiting' => 'En espera',
|
||||||
|
'bud_total_income_smoothed' => 'Total Ingressos (Prorratejats)',
|
||||||
|
'bud_total_expenses_smoothed'=> 'Total Despeses (Prorratejades)',
|
||||||
|
'bud_theoretical_balance_recap'=> 'Equilibri teòric',
|
||||||
|
'bud_recap_footer_note' => 'L\'estat del pagament s\'actualitza automàticament en funció de les operacions importades a la pestanya "Seguiment Mensual".',
|
||||||
|
'bud_recap_modal_add' => 'Afegir un element',
|
||||||
|
'bud_recap_modal_edit' => 'Modificar',
|
||||||
|
'bud_label_name' => 'Nom',
|
||||||
|
'bud_ph_item_name' => 'Ex: Lloguer, Sou...',
|
||||||
|
'bud_label_keywords' => 'Paraules clau (Detecció Auto)',
|
||||||
|
'bud_ph_keywords' => 'Ex: NETFLIX, PRIME VIDEO (separades per coma)',
|
||||||
|
'bud_help_keywords' => 'Si una despesa del mes conté aquesta paraula, la línia passarà a "Validat".',
|
||||||
|
'bud_label_day' => 'Dia (1-31)',
|
||||||
|
'bud_cat_income' => 'Estalvi',
|
||||||
|
'bud_cat_expense' => 'Despesa',
|
||||||
|
'bud_label_frequency' => 'Freqüència',
|
||||||
|
'bud_label_amount_type' => 'Tipus d\'import',
|
||||||
|
'bud_type_fixed' => 'Fix (Factura)',
|
||||||
|
'bud_type_variable' => 'Variable (Estimació)',
|
||||||
|
'bud_label_regularization' => 'Regularització',
|
||||||
|
'bud_reg_none' => 'Cap',
|
||||||
|
'bud_recap_confirm_delete' => 'Segur que vols suprimir aquest element?',
|
||||||
|
'bud_err_delete' => 'Error en suprimir.',
|
||||||
|
'bud_sav_modal_title_add' => 'Introduir un mes',
|
||||||
|
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
|
||||||
|
'bud_prev_label_name' => 'Nom',
|
||||||
|
'bud_prev_monthly_target' => 'Import objectiu / Mensualitat (€)',
|
||||||
|
'bud_prev_transfer_dest' => 'Destinació de la transferència',
|
||||||
|
'bud_prev_none' => 'Cap destinació (Despesa estàndard)',
|
||||||
|
|
||||||
|
// --- BUDGET : EPARGNE ---
|
||||||
|
'bud_sav_add_month' => 'Introduir un mes',
|
||||||
|
'bud_sav_add_one_month' => '+1 Mes',
|
||||||
|
'bud_sav_no_data' => 'Cap dada per a %s.',
|
||||||
|
'bud_sav_post_month' => 'Concepte / Mes',
|
||||||
|
'bud_sav_from_date' => 'Des del %s',
|
||||||
|
'bud_sav_edit_modal' => 'Modificar amb Modal',
|
||||||
|
'bud_sav_delete_month' => 'Suprimir tot el mes',
|
||||||
|
'bud_sav_total_bank' => 'Total Banc',
|
||||||
|
'bud_sav_extra' => 'Extra',
|
||||||
|
'bud_sav_modal_title_add' => 'Introduir un mes',
|
||||||
|
'bud_sav_modal_title_edit' => 'Modificar:',
|
||||||
|
'bud_sav_month_concerned' => 'Mes afectat',
|
||||||
|
'bud_sav_total_bank_eur' => 'Total al Banc (€)',
|
||||||
|
'bud_sav_ventilation' => 'Desglossament',
|
||||||
|
'bud_sav_adj_help' => 'Utilitzeu l\'ajust (+/-) per recalcular automàticament.',
|
||||||
|
'bud_sav_add_line' => 'Línia',
|
||||||
|
'bud_sav_current' => 'Actual',
|
||||||
|
'bud_sav_adjust' => 'Ajust (+/-)',
|
||||||
|
'bud_sav_new' => 'Nou',
|
||||||
|
'bud_sav_sum_mode_title' => 'Activar la suma ràpida',
|
||||||
|
'bud_sav_selection' => 'Selecció:',
|
||||||
|
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
|
||||||
|
'bud_sav_saving' => 'Desant...',
|
||||||
|
'bud_err_tech' => 'S\'ha produït un error tècnic.',
|
||||||
|
'bud_sav_confirm_delete_month' => 'Suprimir TOT el mes de %m per a %o?',
|
||||||
|
'bud_sav_prompt_duplicate' => "Vols copiar les dades de %s a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
|
||||||
|
'bud_err_server' => 'Error del servidor: ',
|
||||||
|
'bud_err_network_dup' => 'Error de xarxa en duplicar.',
|
||||||
|
'budget_tab_kids' => 'Infants 👶',
|
||||||
|
'bud_sav_add_one_month' => '+1 Mes',
|
||||||
|
'bud_sav_add_month' => 'Afegir un mes',
|
||||||
|
'bud_sav_no_data' => 'Cap dada d\'estalvi per a %s.',
|
||||||
|
'bud_sav_post_month' => 'Concepte / Mes',
|
||||||
|
'bud_sav_from_date' => 'Des del %s',
|
||||||
|
'bud_sav_edit_modal' => 'Editar aquest mes',
|
||||||
|
'bud_sav_delete_month' => 'Suprimir aquest mes',
|
||||||
|
'bud_sav_total_bank' => 'TOTAL BANC',
|
||||||
|
'bud_sav_extra' => 'NO ASSIGNAT (EXTRES)',
|
||||||
|
'bud_sav_modal_title_add' => 'Afegir un mes',
|
||||||
|
'bud_sav_modal_title_edit' => 'Editar',
|
||||||
|
'bud_sav_month_concerned' => 'Mes afectat',
|
||||||
|
'bud_sav_total_bank_eur' => 'Total al Banc (€)',
|
||||||
|
'bud_sav_ventilation' => 'Desglossament de comptes',
|
||||||
|
'bud_sav_adj_help' => 'Utilitzeu l\'ajust (+/-) per recalcular automàticament.',
|
||||||
|
'bud_sav_add_line' => 'Afegir una línia',
|
||||||
|
'bud_category' => 'Categoria',
|
||||||
|
'bud_sav_current' => 'Actual',
|
||||||
|
'bud_sav_adjust' => 'Ajust',
|
||||||
|
'bud_sav_new' => 'Nou',
|
||||||
|
'bud_sav_sum_mode_title' => 'Activar/Desactivar la calculadora',
|
||||||
|
'bud_sav_selection' => 'Selecció:',
|
||||||
|
'bud_sav_ph_name' => 'Nom del compte',
|
||||||
|
|
||||||
|
|
||||||
|
// --- Module Budget : Provisions & Optimisation ---
|
||||||
|
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
|
||||||
|
'btn_optimize_cashflow' => 'Optimiser mes Économies',
|
||||||
|
'add_new_provision' => 'Anticiper une grosse dépense à venir',
|
||||||
|
'provision_label' => 'Libellé de la dépense',
|
||||||
|
'provision_placeholder_wood' => 'Ex: Commande de bois, Révision de l\'auto...',
|
||||||
|
'amount' => 'Montant',
|
||||||
|
'expected_date' => 'Date prévue',
|
||||||
|
'btn_add' => 'Ajouter',
|
||||||
|
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
|
||||||
|
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
|
||||||
|
'error_generic' => 'Une erreur est survenue',
|
||||||
|
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
|
||||||
|
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
|
||||||
|
'btn_delete' => 'Supprimer',
|
||||||
|
'savings_this_month' => 'Économies globales à placer ce mois-ci',
|
||||||
|
'calculate' => 'Calculer la stratégie optimale',
|
||||||
|
'date_past_warning' => 'La date sélectionnée est déjà passée !',
|
||||||
|
'budget_tab_provisions' => 'Provisions',
|
||||||
|
|
||||||
|
// JS & Errors
|
||||||
|
'bud_sav_confirm_delete_month' => 'Estàs segur que vols suprimir totes les dades de %m per a %o?',
|
||||||
|
'bud_sav_prompt_duplicate' => "Vols duplicar les dades de %s cap a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
|
||||||
|
'bud_err_tech' => 'S\'ha produït un error tècnic.',
|
||||||
|
'bud_err_server' => 'Error del servidor: ',
|
||||||
|
'bud_err_network_dup' => 'Error de xarxa en duplicar.',
|
||||||
|
'bud_sav_saving' => 'Desant...',
|
||||||
|
'bud_err_delete' => 'Error en suprimir.',
|
||||||
|
|
||||||
|
// --- BUDGET : PREVISIONNEL ---
|
||||||
|
'bud_prev_incomes' => 'Ingressos',
|
||||||
|
'bud_prev_person' => 'Persona',
|
||||||
|
'bud_prev_salary' => 'Sou',
|
||||||
|
'bud_prev_monthly_payment' => 'Mensualitat',
|
||||||
|
'bud_prev_joint_account' => 'Cte Comú',
|
||||||
|
'bud_prev_func_expenses' => 'Desp. Func.',
|
||||||
|
'bud_prev_perso_savings' => 'Est. Perso',
|
||||||
|
'bud_prev_family_savings' => 'Est. Família',
|
||||||
|
'bud_prev_available' => 'Dispo',
|
||||||
|
'bud_prev_perso_remaining' => 'Restant Perso',
|
||||||
|
'bud_prev_budget_alloc' => 'Distribució Pressupostària',
|
||||||
|
'bud_prev_today' => 'Avui',
|
||||||
|
'bud_prev_new_line' => 'Nova Línia',
|
||||||
|
'bud_prev_remaining' => 'Restant',
|
||||||
|
'bud_prev_confirm_del_line' => 'Suprimir aquesta línia i tot el seu historial?',
|
||||||
|
'bud_prev_notes_for' => 'Notes per a',
|
||||||
|
'bud_prev_saved' => 'Desat',
|
||||||
|
'bud_prev_notes_ph' => 'Escriu les teves observacions, recordatoris o comentaris per a aquest mes aquí...',
|
||||||
|
'bud_prev_save_note' => 'Desar la nota',
|
||||||
|
'bud_prev_transfers_to_make' => 'Transferències a fer',
|
||||||
|
'bud_prev_destination' => 'Destinació',
|
||||||
|
'bud_prev_done' => 'FET',
|
||||||
|
'bud_prev_validate' => 'Validar',
|
||||||
|
'bud_prev_grand_total' => 'GRAN TOTAL',
|
||||||
|
'bud_prev_new_line_title' => 'Nova Línia de Pressupost',
|
||||||
|
'bud_prev_edit_line_title' => 'Modificar la línia',
|
||||||
|
'bud_prev_label_target' => 'Objectiu (Destinació)',
|
||||||
|
'bud_prev_choose' => 'Tria',
|
||||||
|
'bud_prev_link_holiday' => 'Enllaçar a un viatge (Opcional)',
|
||||||
|
'bud_prev_no_link' => 'No associar',
|
||||||
|
'bud_prev_err_no_history' => 'Impossible: no hi ha historial disponible per copiar.',
|
||||||
|
'bud_prev_confirm_copy' => "Vols copiar les dades de %s a %t?\n\n⚠️ Això sobreescriurà tots els valors ja presents per a %t.",
|
||||||
|
'bud_prev_confirm_transfers' => "Confirmes que %p ha fet totes les transferències per a %m?\n\nAixò actualitzarà l'Estalvi automàticament.",
|
||||||
|
'bud_prev_target_ph' => 'Ex: 150',
|
||||||
|
'bud_prev_transfer_to' => 'Cap a',
|
||||||
|
|
||||||
|
// --- MODULE BUDGET : AVANCES & TRICOUNT ---
|
||||||
|
'bud_adv_title' => 'Avançaments i Tricount Familiar',
|
||||||
|
'bud_adv_add' => 'Afegir un avançament',
|
||||||
|
'bud_adv_edit' => 'Modificar l\'avançament',
|
||||||
|
'bud_adv_who_paid' => 'Qui ha pagat?',
|
||||||
|
'bud_adv_payer' => 'Pagador',
|
||||||
|
'bud_adv_has_advanced' => '%s ha avançat:',
|
||||||
|
'bud_adv_cc_label' => 'al compte comú',
|
||||||
|
'bud_adv_livret_label' => 'des del seu llibre d\'estalvis',
|
||||||
|
'bud_adv_cc_balance_title' => 'Equilibri del Compte Comú',
|
||||||
|
'bud_adv_owed_to' => 'S\'han de pagar **%s €** a **%s**',
|
||||||
|
'bud_adv_balanced' => 'Els comptes estan perfectament equilibrats!',
|
||||||
|
'bud_adv_saved_badge' => 'Estalvis',
|
||||||
|
'bud_adv_already_saved' => 'Aquests diners provenen d\'un llibre d\'estalvis',
|
||||||
|
'bud_adv_ph_desc' => 'Ex: Compres, Factura d\'electricitat...',
|
||||||
|
'bud_adv_confirm_resolve' => 'Confirmar el reemborsament complet d\'aquest avançament?',
|
||||||
|
'bud_adv_confirm_delete' => 'Eliminar definitivament aquest avançament?',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MÒDUL: REGALS (gift-list)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// 1. Títols i capçaleres
|
||||||
|
'gift_page_title' => 'Llista de Regals',
|
||||||
|
'gift_main_title' => 'Regals de %s', // %s serà substituït per l'any (ex: Regals de 2026)
|
||||||
|
'settings' => 'Configuració',
|
||||||
|
|
||||||
|
// 2. Filtres
|
||||||
|
'gift_filter_all_children' => 'Tots els nens',
|
||||||
|
'gift_filter_all_adults' => 'Tots els adults',
|
||||||
|
|
||||||
|
// 3. Contingut i estats buits
|
||||||
|
'gift_add_gift' => 'Afegir',
|
||||||
|
'gift_empty_state_no_gifts' => 'Cap regal registrat per a aquesta ocasió.',
|
||||||
|
'gift_empty_state_no_filter' => 'Cap regal coincideix amb els teus filtres.',
|
||||||
|
'gift_paid_by' => 'Pagat per %s', // %s serà substituït pel nom del pagador
|
||||||
|
|
||||||
|
// 4. Secció Tricount (Balanç i Reemborsaments)
|
||||||
|
'gift_liquidations' => 'Balanç i Reemborsaments',
|
||||||
|
'gift_no_debt' => 'Tot està al dia, cap deute!',
|
||||||
|
'gift_owes' => 'deu',
|
||||||
|
'gift_view_matrix' => 'Veure la matriu detallada',
|
||||||
|
'gift_debtor' => 'Deutor',
|
||||||
|
'gift_creditor' => 'Creditor',
|
||||||
|
|
||||||
|
// 5. Modal: Afegir/Modificar Regal
|
||||||
|
'gift_col_adult' => 'Per compte de',
|
||||||
|
'gift_modal_payer' => 'Pagat per',
|
||||||
|
'gift_modal_gift_name' => 'Descripció del regal',
|
||||||
|
'gift_modal_price' => 'Preu estimat / pagat (€)',
|
||||||
|
'gift_modal_link' => 'Enllaç del producte (URL)',
|
||||||
|
'gift_modal_title_add' => 'Afegir per a %s', // JS: %s = nom del nen
|
||||||
|
'gift_modal_title_edit' => 'Modificar el regal', // JS
|
||||||
|
'gift_confirm_delete' => 'Vols eliminar realment aquest regal?', // JS
|
||||||
|
|
||||||
|
// 6. Modal: Configuració de les Festes
|
||||||
|
'gift_settings_title' => 'Configuració de les Festes',
|
||||||
|
'gift_settings_add_title' => '+ Afegir una ocasió',
|
||||||
|
'gift_settings_name_placeholder' => 'Nom (ex: Sant Valentí)',
|
||||||
|
'gift_settings_date_placeholder' => 'MM-DD',
|
||||||
|
'gift_settings_date_help' => 'La data (MM-DD) és opcional. Deixa en blanc per a una festa mòbil.',
|
||||||
|
'gift_settings_empty' => 'Cap festa configurada.', // JS
|
||||||
|
'gift_settings_active' => 'Actiu', // JS
|
||||||
|
|
||||||
|
// 7. Botons genèrics
|
||||||
|
'btn_cancel' => 'Cancel·lar',
|
||||||
|
'btn_save' => 'Desar',
|
||||||
|
'btn_add' => 'Afegir',
|
||||||
|
'loading' => 'Carregant...', //
|
||||||
|
|
||||||
|
// --- MODULE CADEAUX : SETTINGS ---
|
||||||
|
'gift_settings' => 'Configuració',
|
||||||
|
'gift_tab_occasions' => 'Festes i Ocasions',
|
||||||
|
'gift_tab_proches' => 'Familiars (Fora de la llar)',
|
||||||
|
'gift_settings_icon' => 'Icona',
|
||||||
|
'gift_settings_date' => 'Data (MM-DD)',
|
||||||
|
'gift_settings_add_occ' => 'Afegeix una festa',
|
||||||
|
'gift_settings_add_proche'=> 'Afegeix un familiar',
|
||||||
|
'gift_role_proche_adulte'=> 'Adult (Oncle, amic...)',
|
||||||
|
'gift_role_proche_enfant'=> 'Nen (Nebot, neboda...)',
|
||||||
|
'gift_ph_proche_name' => 'Nom',
|
||||||
|
'gift_role_label' => 'Rol',
|
||||||
|
'gift_settings_saved' => 'Configuració desada!',
|
||||||
|
'gift_settings_edit_occ' => 'Modificar la festa',
|
||||||
|
'gift_settings_edit_proche' => 'Modificar el familiar',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// GARAGE MANAGER
|
||||||
|
// ==========================================
|
||||||
|
'menu_garage' => 'Garatge',
|
||||||
|
'garage_page_title' => 'HouseHub - Garatge',
|
||||||
|
'garage_stat_vehicles' => 'Vehicles',
|
||||||
|
'garage_stat_maintenances' => 'Manteniments',
|
||||||
|
'garage_stat_parts' => 'Peces',
|
||||||
|
'garage_stat_cost' => 'Cost total',
|
||||||
|
'garage_my_vehicles' => 'Els meus vehicles',
|
||||||
|
'garage_reminders' => 'Recordatoris i propers manteniments',
|
||||||
|
'garage_vehicles_title' => 'Els meus vehicles',
|
||||||
|
'garage_all_parts' => 'Totes les peces',
|
||||||
|
'garage_parts_count' => 'peces',
|
||||||
|
'garage_maintenances' => 'Manteniments',
|
||||||
|
'garage_parts' => 'Peces',
|
||||||
|
'garage_add' => 'Afegir',
|
||||||
|
'garage_add_vehicle' => 'Afegir un vehicle',
|
||||||
|
'garage_add_maintenance' => 'Afegir un manteniment',
|
||||||
|
'garage_add_part' => 'Afegir una peça',
|
||||||
|
'garage_vehicle_name' => 'Nom del vehicle',
|
||||||
|
'garage_brand' => 'Marca',
|
||||||
|
'garage_model' => 'Model',
|
||||||
|
'garage_year' => 'Any',
|
||||||
|
'garage_plate' => 'Matrícula',
|
||||||
|
'garage_fuel' => 'Combustible',
|
||||||
|
'garage_km' => 'Quilometratge actual',
|
||||||
|
'garage_color' => 'Color',
|
||||||
|
'garage_purchase_date' => 'Data de compra',
|
||||||
|
'garage_purchase_price' => 'Preu de compra (€)',
|
||||||
|
'garage_photo' => 'Foto',
|
||||||
|
'garage_notes' => 'Notes',
|
||||||
|
'garage_maint_type' => 'Tipus de manteniment',
|
||||||
|
'garage_date' => 'Data',
|
||||||
|
'garage_km_at' => 'Quilometratge en el moment',
|
||||||
|
'garage_labor_cost' => 'Cost mà d\'obra (€)',
|
||||||
|
'garage_description' => 'Descripció',
|
||||||
|
'garage_mechanic' => 'Mecànic',
|
||||||
|
'garage_garage' => 'Taller',
|
||||||
|
'garage_next_reminder' => 'Proper manteniment (recordatori)',
|
||||||
|
'garage_next_date' => 'Data prevista',
|
||||||
|
'garage_next_km' => 'Quilometratge previst',
|
||||||
|
'garage_part_name' => 'Nom de la peça',
|
||||||
|
'garage_category' => 'Categoria',
|
||||||
|
'garage_reference' => 'Referència',
|
||||||
|
'garage_unit_price' => 'Preu unitari (€)',
|
||||||
|
'garage_quantity' => 'Quantitat',
|
||||||
|
'garage_unit' => 'Unitat',
|
||||||
|
'garage_supplier' => 'Proveïdor',
|
||||||
|
'garage_change_photo' => 'Canviar la foto',
|
||||||
|
'garage_new_photo' => 'Nova foto',
|
||||||
|
'garage_update_photo' => 'Actualitzar',
|
||||||
|
];
|
||||||
@@ -0,0 +1,967 @@
|
|||||||
|
<?php
|
||||||
|
// English Dictionary
|
||||||
|
return [
|
||||||
|
// ==========================================
|
||||||
|
// TEST AUTO
|
||||||
|
// ==========================================
|
||||||
|
'test_running' => 'Running tests...',
|
||||||
|
'test_passed' => 'Test passed',
|
||||||
|
'test_failed' => 'Test failed',
|
||||||
|
'tests_title' => 'Test Lab',
|
||||||
|
'tests_run_budget' => 'Test Budget',
|
||||||
|
'tests_run_all' => 'Run all tests',
|
||||||
|
'tests_copy_report' => 'Copy report',
|
||||||
|
'tests_waiting' => 'Waiting to start...',
|
||||||
|
'tests_report_copied' => 'Report copied to clipboard!',
|
||||||
|
'tests_live_preview' => 'Live preview',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// GLOBAL & NAVIGATION
|
||||||
|
// ==========================================
|
||||||
|
'menu_home' => 'Home',
|
||||||
|
'menu_calendar' => 'Calendar',
|
||||||
|
'menu_budget' => 'Budget',
|
||||||
|
'menu_holidays' => 'Trips',
|
||||||
|
'menu_gifts' => 'Gifts',
|
||||||
|
'menu_calendar_ios' => 'iOS Calendar',
|
||||||
|
'nav_home' => 'Home',
|
||||||
|
'nav_calendar' => 'Calendar',
|
||||||
|
'nav_budget' => 'Budget',
|
||||||
|
'nav_holidays' => 'Trips',
|
||||||
|
'nav_gifts' => 'Gifts',
|
||||||
|
'aria_menu' => 'Mobile menu',
|
||||||
|
|
||||||
|
// Buttons & global actions
|
||||||
|
'actions' => 'Actions',
|
||||||
|
'edit' => 'Edit',
|
||||||
|
'delete' => 'Delete',
|
||||||
|
'save' => 'Save',
|
||||||
|
'cancel' => 'Cancel',
|
||||||
|
'btn_save' => 'Save',
|
||||||
|
'btn_cancel' => 'Cancel',
|
||||||
|
'btn_delete' => 'Delete',
|
||||||
|
'btn_close' => 'Close',
|
||||||
|
'btn_back' => '◀ Back',
|
||||||
|
'btn_edit_bases' => '⚙️ Edit base values',
|
||||||
|
'btn_edit' => 'Edit',
|
||||||
|
'error_occured' => 'An error occurred.',
|
||||||
|
|
||||||
|
// Generic words & errors
|
||||||
|
'error' => 'Error.',
|
||||||
|
'confirm_delete' => 'Are you sure you want to delete this item?',
|
||||||
|
|
||||||
|
// Months & Days
|
||||||
|
'month_01' => 'January', 'month_02' => 'February', 'month_03' => 'March',
|
||||||
|
'month_04' => 'April', 'month_05' => 'May', 'month_06' => 'June',
|
||||||
|
'month_07' => 'July', 'month_08' => 'August', 'month_09' => 'September',
|
||||||
|
'month_10' => 'October', 'month_11' => 'November', 'month_12' => 'December',
|
||||||
|
'day_mon' => 'Mon', 'day_tue' => 'Tue', 'day_wed' => 'Wed',
|
||||||
|
'day_thu' => 'Thu', 'day_fri' => 'Fri', 'day_sat' => 'Sat', 'day_sun' => 'Sun',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// AUTHENTICATION / LOGIN
|
||||||
|
// ==========================================
|
||||||
|
'login_title' => 'Login - HouseHub',
|
||||||
|
'login_header' => 'Login',
|
||||||
|
'label_username' => 'Username',
|
||||||
|
'label_password' => 'Password',
|
||||||
|
'placeholder_username' => 'e.g. john',
|
||||||
|
'btn_login' => 'Login',
|
||||||
|
'btn_login_submit' => 'Sign in',
|
||||||
|
'logout' => 'Logout',
|
||||||
|
'btn_logout' => 'Logout',
|
||||||
|
'error_missing_fields' => 'All fields are required.',
|
||||||
|
'error_invalid_credentials' => 'Invalid credentials.',
|
||||||
|
'login_subtitle' => 'Please sign in to continue',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HOME / INDEX
|
||||||
|
// ==========================================
|
||||||
|
'home_title' => 'HouseHub - Home',
|
||||||
|
'home_welcome' => 'Welcome to HouseHub',
|
||||||
|
'home_subtitle' => 'Family organisation control centre.',
|
||||||
|
'home_logged_as' => 'Logged in as',
|
||||||
|
'home_modules_title' => 'Modules',
|
||||||
|
'mod_calendar_name' => 'Family Calendar',
|
||||||
|
'mod_calendar_desc' => 'Manage leave, childcare arrangements and weekly planning.',
|
||||||
|
'mod_holidays_name' => 'Trips',
|
||||||
|
'mod_holidays_desc' => 'Plan your next getaways, destinations and travel ideas.',
|
||||||
|
'mod_gifts_name' => 'Gifts',
|
||||||
|
'mod_gifts_desc' => 'Plan gifts for each child, for Christmas and birthdays.',
|
||||||
|
'mod_budget_name' => 'Budget',
|
||||||
|
'mod_budget_desc' => 'Track fixed expenses, income and family account balance.',
|
||||||
|
'mod_garage_name' => 'Garage',
|
||||||
|
'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.',
|
||||||
|
'mod_memo_name' => 'Notes',
|
||||||
|
'mod_memo_desc' => 'Your memos, recipes, instructions and miscellaneous info with Markdown and attachments.',
|
||||||
|
'menu_memo' => 'Notes',
|
||||||
|
'mod_todo_name' => 'Todo',
|
||||||
|
'mod_todo_desc' => 'Manage tasks, shopping lists and family reminders.',
|
||||||
|
'mod_calendar_ios_name' => 'iOS Calendar',
|
||||||
|
'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).',
|
||||||
|
'menu_todo' => 'Todo',
|
||||||
|
'mod_groceries_name' => 'Groceries',
|
||||||
|
'mod_groceries_desc' => 'Shared list: add items and check them off when they are in your cart.',
|
||||||
|
'menu_groceries' => 'Groceries',
|
||||||
|
'groceries_placeholder_add' => 'e.g. milk, bread, tomatoes…',
|
||||||
|
'groceries_btn_add' => 'Add',
|
||||||
|
'groceries_btn_uncheck_all' => 'Reset cart checks',
|
||||||
|
'groceries_btn_delete_picked' => 'Remove checked items',
|
||||||
|
'groceries_subtitle' => '{pending} to pick up · {cart} in cart',
|
||||||
|
'groceries_added' => 'Added',
|
||||||
|
'groceries_updated' => 'Updated',
|
||||||
|
'groceries_deleted' => 'Removed',
|
||||||
|
'groceries_reset_next' => 'List ready for your next trip',
|
||||||
|
'groceries_picked_removed' => 'Checked items removed',
|
||||||
|
'groceries_label_empty' => 'Label cannot be empty',
|
||||||
|
'groceries_confirm_del_title' => 'Delete?',
|
||||||
|
'groceries_confirm_del_body' => 'Remove this product from the list?',
|
||||||
|
'groceries_confirm_uncheck_title' => 'Reset cart checks',
|
||||||
|
'groceries_confirm_uncheck_body' => 'All “in cart” checkmarks will be cleared. Items stay on the list.',
|
||||||
|
'groceries_confirm_clear_title' => 'Remove checked items',
|
||||||
|
'groceries_confirm_clear_body' => 'Every checked item will be deleted from the list.',
|
||||||
|
'groceries_empty' => 'Nothing yet. Add a product above.',
|
||||||
|
'groceries_aria_toggle' => 'Mark as in cart or still to pick up',
|
||||||
|
'groceries_section_pending' => 'To pick up',
|
||||||
|
'groceries_section_cart' => 'In cart',
|
||||||
|
'groceries_prompt_edit' => 'Edit product',
|
||||||
|
'groceries_btn_clear_list' => 'Clear list',
|
||||||
|
'groceries_confirm_empty_list_title' => 'Clear the entire list?',
|
||||||
|
'groceries_confirm_empty_list_body' => 'All products will be removed. They stay in history so you can add them again quickly.',
|
||||||
|
'groceries_list_cleared' => 'List cleared',
|
||||||
|
'groceries_history_title' => 'History — click to add',
|
||||||
|
'groceries_history_empty' => 'History fills as you add products (up to the limit set in Settings).',
|
||||||
|
'groceries_history_meta' => 'Up to {max} products · ',
|
||||||
|
'groceries_settings_link' => 'Settings',
|
||||||
|
'groceries_settings_title' => 'Groceries — history',
|
||||||
|
'groceries_settings_intro' => 'Maximum number of products remembered in history (no duplicates, case-insensitive).',
|
||||||
|
'groceries_settings_history_max' => 'History size',
|
||||||
|
'groceries_settings_history_hint' => 'Between 1 and 50. Oldest entries are removed when the limit is exceeded.',
|
||||||
|
'groceries_settings_saved' => 'Grocery preferences saved.',
|
||||||
|
'groceries_settings_error' => 'Could not save:',
|
||||||
|
'groceries_err_duplicate' => 'This product is already on the list.',
|
||||||
|
'cta_open' => 'Open',
|
||||||
|
'cta_explore' => 'Explore',
|
||||||
|
'cta_view_lists' => 'Unwrap',
|
||||||
|
'cta_manage' => 'Manage',
|
||||||
|
'cta_service' => 'Service',
|
||||||
|
'cta_jot' => 'Jot down',
|
||||||
|
'cta_check' => 'Check off',
|
||||||
|
'cta_cart' => 'Go shopping',
|
||||||
|
'cta_sync' => 'Sync',
|
||||||
|
'mod_printvault_name' => 'PrintVault',
|
||||||
|
'mod_printvault_desc' => 'Manage your 3D print files — STL, 3MF, GCode with interactive viewer.',
|
||||||
|
'menu_printvault' => 'PrintVault',
|
||||||
|
'cta_print' => 'Print',
|
||||||
|
'mod_planka_name' => 'Planka',
|
||||||
|
'mod_planka_desc' => 'Manage your projects in Kanban mode with Planka.',
|
||||||
|
'menu_planka' => 'Planka',
|
||||||
|
|
||||||
|
// --- SETTINGS : KIDS ---
|
||||||
|
'set_kids_title' => 'Household Kids',
|
||||||
|
'set_kids_desc' => 'Manage the list of kids to associate them with savings, gifts, etc.',
|
||||||
|
'set_add_kid_placeholder' => 'Kid\'s first name',
|
||||||
|
'set_btn_add_kid' => 'Add',
|
||||||
|
'set_err_kid_name' => 'The first name is required.',
|
||||||
|
'set_confirm_del_kid' => 'Delete this kid? (Their financial history will remain visible in the archives).',
|
||||||
|
'set_no_kids' => 'No kids configured for this household.',
|
||||||
|
'set_family_config' => 'Family Structure',
|
||||||
|
'set_global_params' => 'Global settings',
|
||||||
|
'set_care_modes' => 'Common childcare arrangements',
|
||||||
|
'set_add_care_mode' => 'Ajouter un modeAdd a mode',
|
||||||
|
'btn_save' => 'Save',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// FAMILY CALENDAR
|
||||||
|
// ==========================================
|
||||||
|
'fc_page_title' => 'HouseHub - Calendar',
|
||||||
|
'fc_main_header' => 'Family Calendar',
|
||||||
|
'fc_btn_correct_balances' => 'Adjust balances',
|
||||||
|
'fc_btn_school_holidays' => 'School holidays',
|
||||||
|
'fc_modal_holidays_title' => 'School Holidays',
|
||||||
|
'fc_modal_snap_title' => 'Adjust a balance',
|
||||||
|
'fc_col_period' => 'Period',
|
||||||
|
'fc_col_from' => 'From',
|
||||||
|
'fc_col_to' => 'To',
|
||||||
|
'fc_label_person' => 'Person',
|
||||||
|
'fc_label_leave_type' => 'Leave type',
|
||||||
|
'fc_label_apply_date' => 'Application date',
|
||||||
|
'fc_snap_date_hint' => 'Preferably choose the 1st day of a month',
|
||||||
|
'fc_label_remaining_balance'=> 'Remaining balance at this date',
|
||||||
|
'fc_placeholder_balance' => 'e.g. 12.5',
|
||||||
|
'fc_btn_save_snap' => 'Save adjustment',
|
||||||
|
'fc_view_1m' => '1 month',
|
||||||
|
'fc_view_2m' => '2 months',
|
||||||
|
'fc_view_3m' => '3 months',
|
||||||
|
'fc_view_year' => 'Year',
|
||||||
|
'fc_weekly_planning' => 'Weekly planning',
|
||||||
|
'fc_col_month' => 'Month',
|
||||||
|
'fc_col_week_short' => 'Wk.',
|
||||||
|
'fc_col_av' => 'Av.',
|
||||||
|
'fc_col_use' => 'Use',
|
||||||
|
'fc_legend_title' => 'Legend',
|
||||||
|
'fc_summary_title' => 'Summary',
|
||||||
|
'fc_summ_year' => 'Year',
|
||||||
|
'fc_summ_month' => 'Month',
|
||||||
|
'fc_school_year' => 'School year',
|
||||||
|
'fc_unit_days' => 'days',
|
||||||
|
'fc_menu_carole' => 'Carole\'s Leave',
|
||||||
|
'fc_clear' => 'Clear',
|
||||||
|
'fc_menu_kids_leaves' => 'Children\'s Leave',
|
||||||
|
'fc_err_save_snap' => 'Error saving adjustment.',
|
||||||
|
'fc_err_fetch_gov' => 'Error connecting to the government API.',
|
||||||
|
'fc_err_no_data_gov' => 'No data found on the government API for this year.',
|
||||||
|
'fc_err_action' => 'Error during action: ',
|
||||||
|
'fc_today_short' => 'Today',
|
||||||
|
'fc_today_title' => 'Today',
|
||||||
|
'fc_tab_foyer' => '🏡 Household',
|
||||||
|
'fc_tab_members' => '👥 Members & Rights',
|
||||||
|
'fc_select_member' => 'Select a family member:',
|
||||||
|
'fc_role_adult' => 'Adult',
|
||||||
|
'fc_role_child' => 'Child',
|
||||||
|
'fc_care_modes_title' => 'Associated Care Modes',
|
||||||
|
'fc_care_modes_desc' => 'Check the care modes that apply to this child.',
|
||||||
|
'fc_no_care_mode' => 'No care mode configured.',
|
||||||
|
'fc_matrix_title' => '👨👩👦 Leave Rights Matrix',
|
||||||
|
'fc_matrix_desc' => 'Declare your counter codes and renewal dates.',
|
||||||
|
'fc_matrix_code' => 'Code',
|
||||||
|
'fc_matrix_date' => 'Reset Date',
|
||||||
|
'fc_matrix_empty' => 'No counter configured.',
|
||||||
|
'fc_matrix_saved' => 'Rights matrix updated successfully! 💼',
|
||||||
|
'fc_child_saved' => 'Child subscription saved!',
|
||||||
|
'btn_add_counter' => '➕ Add',
|
||||||
|
'btn_save_rights' => '💾 Save',
|
||||||
|
'fc_modal_settings_title' => 'Calendar Settings',
|
||||||
|
'fc_zone_label' => 'School Zone (Holidays)',
|
||||||
|
'fc_zone_desc' => 'Determines the automatic school holiday calendar.',
|
||||||
|
'fc_care_modes_label' => 'Household Care Modes',
|
||||||
|
'fc_care_modes_desc' => 'Manage the global list of possible care modes for the family.',
|
||||||
|
'fc_add_mode_placeholder' => 'E.g., Nanny, Center, Grandparents...',
|
||||||
|
'fc_settings_updated' => 'Settings updated successfully!',
|
||||||
|
'fc_first_day' => 'First day of the week',
|
||||||
|
// --- CALENDAR: SETTINGS (MEMBERS & DISPLAY) ---
|
||||||
|
'fc_tab_display' => '🖥️ Display',
|
||||||
|
'fc_display_title' => 'Display Settings',
|
||||||
|
'fc_display_desc' => 'Customize the default appearance and behavior of your calendar.',
|
||||||
|
'fc_default_view' => 'Default View',
|
||||||
|
'fc_view_month' => '1 month',
|
||||||
|
'fc_view_2months' => '2 months',
|
||||||
|
'fc_view_3months' => '3 months',
|
||||||
|
'fc_working_hours' => 'Default Care Hours',
|
||||||
|
'fc_working_hours_desc' => 'Example: 08:30-18:00',
|
||||||
|
|
||||||
|
'fc_role_unknown' => 'Unknown',
|
||||||
|
'fc_role_adult' => 'Adult',
|
||||||
|
'fc_role_child' => 'Child',
|
||||||
|
'fc_role_helper' => 'Helper',
|
||||||
|
'fc_error' => 'Error: ',
|
||||||
|
|
||||||
|
'fc_care_modes_title' => 'Default Care Modes',
|
||||||
|
'fc_care_modes_desc' => 'Which care modes apply to this child?',
|
||||||
|
'fc_no_care_mode' => 'No care mode configured for this household.',
|
||||||
|
'fc_child_saved' => 'Saved!',
|
||||||
|
|
||||||
|
'fc_matrix_title' => 'Leave Matrix',
|
||||||
|
'fc_matrix_desc' => 'Define quotas and renewal dates.',
|
||||||
|
'fc_matrix_empty' => 'No counters defined.',
|
||||||
|
"fc_matrix_saved" => "Matrix saved!",
|
||||||
|
|
||||||
|
'fc_leave_obsolete' => 'Obsolete',
|
||||||
|
'fc_leave_code_placeholder' => 'Code...',
|
||||||
|
'fc_leave_method_fixed' => 'Fixed',
|
||||||
|
'fc_leave_method_accumulated' => 'Accrued',
|
||||||
|
|
||||||
|
'fc_date_format_placeholder' => 'DD/MM',
|
||||||
|
'fc_date_format_title' => 'DD/MM Format',
|
||||||
|
|
||||||
|
'fc_table_code' => 'Code',
|
||||||
|
'fc_table_method' => 'Method',
|
||||||
|
'fc_table_quota' => 'Quota',
|
||||||
|
'fc_table_renewal' => 'Renewal',
|
||||||
|
|
||||||
|
'btn_save_rights' => 'Save',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CALENDAR SETTINGS MODAL
|
||||||
|
// ==========================================
|
||||||
|
'fc_modal_settings_title' => 'Calendar Settings',
|
||||||
|
'fc_tab_foyer' => 'Household',
|
||||||
|
'fc_tab_members' => 'Members & Rights',
|
||||||
|
|
||||||
|
// Household: School Zone
|
||||||
|
'fc_zone_label' => 'School zone',
|
||||||
|
'fc_zone_a' => 'Zone A',
|
||||||
|
'fc_zone_b' => 'Zone B',
|
||||||
|
'fc_zone_c' => 'Zone C',
|
||||||
|
'fc_zone_other' => 'Other / Outside France',
|
||||||
|
'fc_zone_desc' => 'Used to accurately display school holidays.',
|
||||||
|
|
||||||
|
// Household: Care Modes
|
||||||
|
'fc_care_modes_label' => 'Childcare options',
|
||||||
|
'fc_add_mode_placeholder' => 'Ex: Nanny, Daycare...',
|
||||||
|
'fc_care_modes_desc' => 'Add the different childcare options for your children.',
|
||||||
|
'fc_empty_kids_msg' => 'No children are configured in your household yet.',
|
||||||
|
'fc_link_settings' => 'Go to general settings',
|
||||||
|
|
||||||
|
// Household: Leave Catalog
|
||||||
|
'fc_leave_catalog_title' => 'Leave Catalog',
|
||||||
|
'fc_leave_catalog_desc' => 'Define the possible leave types for the whole family.',
|
||||||
|
'fc_add_leave_type' => 'Add a leave type',
|
||||||
|
'fc_col_code' => 'Code',
|
||||||
|
'fc_code_irreversible' => 'Irreversible',
|
||||||
|
'fc_col_label' => 'Label',
|
||||||
|
'fc_leave_label_ph' => 'Ex: PTO, Sick leave...',
|
||||||
|
'fc_quota_default' => 'Default quota',
|
||||||
|
'fc_reset_month' => 'Reset month',
|
||||||
|
|
||||||
|
// Members
|
||||||
|
'fc_select_member' => 'Select a member:',
|
||||||
|
|
||||||
|
// Months
|
||||||
|
'month_jan' => 'January',
|
||||||
|
'month_feb' => 'February',
|
||||||
|
'month_mar' => 'March',
|
||||||
|
'month_apr' => 'April',
|
||||||
|
'month_may' => 'May',
|
||||||
|
'month_jun' => 'June',
|
||||||
|
'month_jul' => 'July',
|
||||||
|
'month_aug' => 'August',
|
||||||
|
'month_sep' => 'September',
|
||||||
|
'month_oct' => 'October',
|
||||||
|
'month_nov' => 'November',
|
||||||
|
'month_dec' => 'December',
|
||||||
|
|
||||||
|
'leg_presence' => 'Pep presence',
|
||||||
|
'leg_school_holidays' => 'School holidays',
|
||||||
|
'leg_public_holiday' => 'Public holiday',
|
||||||
|
'leg_off_carole' => 'Carole off',
|
||||||
|
'leg_extra_off' => 'Extra off',
|
||||||
|
'leg_centre' => 'Centre',
|
||||||
|
'leg_avis' => 'Notice',
|
||||||
|
'leg_pep_sick' => 'Pep sick',
|
||||||
|
'care_nounou' => 'Nanny',
|
||||||
|
'care_centre' => 'Daycare',
|
||||||
|
'care_avis' => 'Grandparents',
|
||||||
|
|
||||||
|
'vac_toussaint' => 'All Saints\' holidays',
|
||||||
|
'vac_noel' => 'Christmas holidays',
|
||||||
|
'vac_hiver' => 'Winter holidays',
|
||||||
|
'vac_printemps' => 'Spring holidays',
|
||||||
|
'vac_ascension' => 'Ascension bridge',
|
||||||
|
'vac_ete' => 'Summer holidays',
|
||||||
|
'btn_off' => 'Off',
|
||||||
|
'btn_extra' => 'Extra',
|
||||||
|
|
||||||
|
'fc_alert_burn_days' => '⚠️ Risk of losing: %s d to take before %s',
|
||||||
|
'fc_alert_burn_jra' => '⚠️ JRA: Only 2 days can be carried over after February!',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HOLIDAYS (TRIPS)
|
||||||
|
// ==========================================
|
||||||
|
'hdl_title_list' => 'HouseHub - My Trips',
|
||||||
|
'hdl_title_detail' => 'HouseHub - Trip details',
|
||||||
|
'hdl_main_title' => 'My Trips',
|
||||||
|
'hdl_filter_all' => 'Show all',
|
||||||
|
'hdl_filter_year' => 'Year',
|
||||||
|
'hdl_left_to_pay_label' => 'Left to pay',
|
||||||
|
'hdl_btn_create' => 'Create a trip',
|
||||||
|
'hdl_no_active_trips' => 'No active trips for this period. Let\'s plan something!',
|
||||||
|
'hdl_history_title' => 'History',
|
||||||
|
'hdl_dates_to_define' => 'Dates to be defined',
|
||||||
|
'hdl_total_budget' => 'Total Budget',
|
||||||
|
'hdl_paid' => 'Paid',
|
||||||
|
'hdl_saved' => 'Funded',
|
||||||
|
'hdl_left_to_pay' => 'Left to pay',
|
||||||
|
'hdl_status_booked' => 'Confirmed',
|
||||||
|
'hdl_status_planned' => 'Planned',
|
||||||
|
'hdl_status_draft' => 'Draft',
|
||||||
|
'hdl_status_passed' => 'Completed',
|
||||||
|
'hdl_status_archived' => 'Archived',
|
||||||
|
'hdl_modal_title' => 'Plan the trip',
|
||||||
|
'hdl_label_name' => 'Trip name',
|
||||||
|
'hdl_ph_name' => 'e.g. October - Portugal',
|
||||||
|
'hdl_label_status' => 'Status',
|
||||||
|
'hdl_label_period' => 'Period (free text)',
|
||||||
|
'hdl_ph_period' => 'e.g. October 2026',
|
||||||
|
'hdl_label_from' => 'From',
|
||||||
|
'hdl_label_to' => 'To',
|
||||||
|
'hdl_cat_transport' => 'Transport',
|
||||||
|
'hdl_add_transport' => 'Add transport',
|
||||||
|
'hdl_cat_accommodation' => 'Accommodation',
|
||||||
|
'hdl_add_accommodation' => 'Add accommodation',
|
||||||
|
'hdl_cat_activity' => 'Activity',
|
||||||
|
'hdl_add_activity' => 'Add activity',
|
||||||
|
'hdl_label_budget_food' => 'Food & Drinks budget (€)',
|
||||||
|
'hdl_label_budget_extras' => 'Extras budget (€)',
|
||||||
|
'hdl_label_notes' => 'Notes',
|
||||||
|
'hdl_ph_notes' => 'Rough ideas...',
|
||||||
|
'hdl_label_budget_food_extras' => 'Food & Extras budget',
|
||||||
|
'hdl_map_itinerary' => 'Itinerary',
|
||||||
|
'hdl_outbound' => 'Outbound',
|
||||||
|
'hdl_return' => 'Return',
|
||||||
|
'hdl_btn_add_step' => 'Add a stop',
|
||||||
|
'hdl_step_details' => 'Road trip details',
|
||||||
|
'hdl_no_steps' => 'No stops defined yet. Start by adding one!',
|
||||||
|
'hdl_general_costs' => 'General costs & budgets',
|
||||||
|
'hdl_food_bev' => 'Food & Drinks',
|
||||||
|
'hdl_extras' => 'Extras',
|
||||||
|
'hdl_view_planning' => 'View planning',
|
||||||
|
'hdl_passing_point' => 'Passing point (no cost)',
|
||||||
|
'hdl_search_location' => 'Search a location',
|
||||||
|
'hdl_ph_search' => 'City, hotel, address...',
|
||||||
|
'hdl_label_step_name' => 'Stop name',
|
||||||
|
'hdl_label_arrival' => 'Arrival',
|
||||||
|
'hdl_label_departure' => 'Departure',
|
||||||
|
'hdl_planned_expenses' => 'Planned expenses for this stop',
|
||||||
|
'hdl_btn_add_expense' => '+ Add an expense',
|
||||||
|
'hdl_save_fav' => 'Save to favourites',
|
||||||
|
'hdl_planning_title' => 'Stop planning',
|
||||||
|
'hdl_to_pay' => 'To pay',
|
||||||
|
'hdl_js_search_loading' => 'Searching...',
|
||||||
|
'hdl_js_no_result' => 'No results found.',
|
||||||
|
'hdl_js_network_error' => 'Connection error to map service.',
|
||||||
|
'hdl_js_confirm_del_trip' => 'Are you sure you want to delete this entire trip?',
|
||||||
|
'hdl_js_confirm_del_step' => 'Delete this stop?',
|
||||||
|
'hdl_js_step_label' => 'Stop',
|
||||||
|
'hdl_js_ph_expense_name' => 'e.g. Hotel, Toll...',
|
||||||
|
'hdl_quick_edit_title' => 'Quick edit',
|
||||||
|
'hdl_js_edit_step' => 'Edit stop',
|
||||||
|
'hdl_js_missing_dates_title'=> 'Missing dates',
|
||||||
|
'hdl_js_missing_dates_msg' => 'Please set an arrival and departure date to organise the planning.',
|
||||||
|
'hdl_to_place' => 'To place',
|
||||||
|
'hdl_from' => 'From',
|
||||||
|
'hdl_to' => 'To',
|
||||||
|
'weather_forecast' => 'Weather forecast',
|
||||||
|
'weather_not_available' => 'Weather unavailable',
|
||||||
|
'temp_max' => 'Max temp.',
|
||||||
|
'weather_loading' => 'Loading weather...',
|
||||||
|
'weather_error' => 'Weather unavailable',
|
||||||
|
'weather_sunny' => 'Sunny',
|
||||||
|
'weather_cloudy' => 'Cloudy',
|
||||||
|
'weather_rainy' => 'Rainy',
|
||||||
|
'weather_snowy' => 'Snowy',
|
||||||
|
'weather_historical' => 'Based on historical data (estimate)',
|
||||||
|
'err_invalid_holiday_id' => 'Invalid trip ID.',
|
||||||
|
'hdl_default_exp_name' => 'Expense',
|
||||||
|
'hdl_select_beneficiary' => '-- Select --',
|
||||||
|
|
||||||
|
// Legacy holiday keys (kept for compatibility)
|
||||||
|
'outbound_trip' => 'Outbound',
|
||||||
|
'return_trip' => 'Return',
|
||||||
|
'period' => 'Period',
|
||||||
|
'budget_food' => 'Food & Extras budget',
|
||||||
|
'total_cost' => 'Total Estimated Cost',
|
||||||
|
'paid' => 'Paid',
|
||||||
|
'provisioned' => 'Provisioned',
|
||||||
|
'left_to_pay' => 'Left to pay',
|
||||||
|
'map_itinerary' => '🗺️ Itinerary',
|
||||||
|
'place_step' => '📍 Place a stop',
|
||||||
|
'step_details' => '📝 Stop details',
|
||||||
|
'no_steps' => 'No stops planned.',
|
||||||
|
'travel_notes' => '📝 Trip notes',
|
||||||
|
'to_be_defined' => 'To be defined',
|
||||||
|
'funded' => 'Funded (Provision)',
|
||||||
|
'view_planning' => 'View planning',
|
||||||
|
'to_pay' => 'To pay',
|
||||||
|
'from_date' => 'From',
|
||||||
|
'to_date' => 'to',
|
||||||
|
'passing_point' => 'Passing point (no expense)',
|
||||||
|
'search_location' => 'Search a geographical location',
|
||||||
|
'search_placeholder' => 'e.g. Paris, Hotel Barcelona...',
|
||||||
|
'step_name' => 'Stop name',
|
||||||
|
'arrival_optional' => 'Arrival (Optional)',
|
||||||
|
'departure_optional' => 'Departure (Optional)',
|
||||||
|
'planned_expenses' => 'Planned expenses',
|
||||||
|
'add_expense' => '+ Add an expense',
|
||||||
|
'save_favorite' => 'Save to favourites',
|
||||||
|
'planning_title' => 'Planning',
|
||||||
|
'modal_plan_trip' => 'Plan the trip',
|
||||||
|
'modal_quick_edit' => 'Quick edit',
|
||||||
|
'err_modal_missing' => 'Error: holidayModal not found.',
|
||||||
|
'placeholder_title' => 'Title',
|
||||||
|
'placeholder_price' => 'Price (€)',
|
||||||
|
'already_paid' => 'Already paid?',
|
||||||
|
'remove_line' => 'Remove this line',
|
||||||
|
'confirm_delete_trip' => 'Are you sure you want to permanently delete this trip? This action cannot be undone.',
|
||||||
|
'step_number' => 'Stop',
|
||||||
|
'place_new_step' => '📍 Place a new stop',
|
||||||
|
'edit_step' => '✏️ Edit stop',
|
||||||
|
'search_in_progress' => 'Searching... ⏳',
|
||||||
|
'no_result_found' => 'No results found.',
|
||||||
|
'network_error' => 'Network error.',
|
||||||
|
'placeholder_label' => 'Label',
|
||||||
|
'placeholder_notes_link' => '🔗 Booking link or notes (Optional)...',
|
||||||
|
'confirm_delete_step' => 'Delete this stop and all its associated expenses?',
|
||||||
|
'planning_of' => '📅 Planning: ',
|
||||||
|
'missing_dates_title' => '⚠️ Missing dates',
|
||||||
|
'missing_dates_msg' => 'Please edit this stop (✏️) and fill in the <strong>arrival and departure dates</strong>.',
|
||||||
|
'to_place' => '📥 To place',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BUDGET (GLOBAL & CATEGORIES)
|
||||||
|
// ==========================================
|
||||||
|
'budget_page_title' => 'HouseHub - Budget',
|
||||||
|
'budget_main_header' => 'Budget Management',
|
||||||
|
'budget_tab_tracking' => 'Monthly tracking',
|
||||||
|
'budget_tab_prev' => 'Budget 2026',
|
||||||
|
'budget_tab_savings' => 'Savings',
|
||||||
|
'budget_tab_recap' => 'Summary',
|
||||||
|
'budget_err_file_not_found' => 'Oops! File not found.',
|
||||||
|
'budget_err_file_detail' => 'Check that the file views/%s.php exists.',
|
||||||
|
'budget_err_not_authorized' => 'Page not authorised.',
|
||||||
|
'cat_income' => 'Income',
|
||||||
|
'cat_fmcg' => 'Groceries (FMCG)',
|
||||||
|
'cat_fuel' => 'Fuel',
|
||||||
|
'cat_school' => 'School / Childcare',
|
||||||
|
'cat_fixed' => 'Fixed costs',
|
||||||
|
'cat_others' => 'Other / Unexpected',
|
||||||
|
'cat_savings' => 'Savings',
|
||||||
|
'budget_tab_kids' => 'Kids',
|
||||||
|
'budget_settings_title' => 'Budget Settings',
|
||||||
|
'bs_tab_accounts' => '💳 Accounts',
|
||||||
|
'bs_tab_categories' => '🏷️ Categories',
|
||||||
|
'bs_tab_rules' => '🤖 Import Rules',
|
||||||
|
'bs_tab_salaries' => '💼 Salaries',
|
||||||
|
'bs_empty_accounts' => 'No accounts configured.',
|
||||||
|
'bs_type_checking' => 'Checking',
|
||||||
|
'bs_type_savings' => 'Savings',
|
||||||
|
'bs_badge_default' => 'Default',
|
||||||
|
'bs_owner' => 'Owner',
|
||||||
|
'bs_shared_account' => 'Joint Account',
|
||||||
|
'bs_empty_categories' => 'No categories configured.',
|
||||||
|
'bs_type_income' => 'Income',
|
||||||
|
'bs_type_expense' => 'Expense',
|
||||||
|
'bs_type_savings_cat' => 'Savings',
|
||||||
|
'bs_empty_rules' => 'No import rules configured.',
|
||||||
|
'bs_empty_salaries' => 'No salary defined for the year',
|
||||||
|
'bs_salaries_year_desc' => 'Configurations for the year',
|
||||||
|
'bs_salary_net' => 'Net salary',
|
||||||
|
'bs_salary_fees' => 'Monthly payment / Fees',
|
||||||
|
'bs_add_account_title' => 'Add an account',
|
||||||
|
'bs_account_name_ph' => 'Name (e.g., Baby Savings)',
|
||||||
|
'bs_confirm_delete' => 'Are you sure you want to delete this account?',
|
||||||
|
'bs_add_category_title' => '+ Add a category',
|
||||||
|
'bs_cat_code_ph' => 'Code (e.g., FOOD)',
|
||||||
|
'bs_cat_label_ph' => 'Label (e.g., Groceries)',
|
||||||
|
'bs_cat_icon_ph' => 'Icon (e.g., 🛒)',
|
||||||
|
'bs_confirm_delete_cat' => 'Are you sure you want to delete this category?',
|
||||||
|
'bs_add_rule_title' => 'Add a rule',
|
||||||
|
'bs_rule_keyword_ph' => 'Keyword (e.g., WALMART)',
|
||||||
|
'bs_confirm_delete_rule'=> 'Are you sure you want to delete this rule?',
|
||||||
|
'bs_currency_label' => 'Household Currency',
|
||||||
|
'bs_currency_desc' => 'Main currency used for displaying your accounts and budgets.',
|
||||||
|
'bs_currency_updated' => 'Currency updated successfully!',
|
||||||
|
// ONGLET CSV MAPPING
|
||||||
|
'bs_csv_title' => 'CSV Import Format',
|
||||||
|
'bs_csv_desc' => 'Configure the columns that match your bank\'s export file.',
|
||||||
|
'bs_csv_delimiter' => 'Delimiter',
|
||||||
|
'bs_csv_delim_semi' => 'Semicolon (;)',
|
||||||
|
'bs_csv_delim_comma' => 'Comma (,)',
|
||||||
|
'bs_csv_delim_tab' => 'Tab',
|
||||||
|
'bs_csv_date_format' => 'Date Format',
|
||||||
|
'bs_csv_col_index' => 'Column Indexes',
|
||||||
|
'bs_csv_col_index_help' => '(Note: the 1st column is Number 0)',
|
||||||
|
'bs_csv_col_date' => 'Col #: Date',
|
||||||
|
'bs_csv_col_label' => 'Col #: Label / Description',
|
||||||
|
'bs_csv_amount_mgmt' => 'Amount Management',
|
||||||
|
'bs_csv_amount_single' => 'Single column (Positive = Income, Negative = Expense)',
|
||||||
|
'bs_csv_amount_split' => 'Two separate columns (Debit / Credit)',
|
||||||
|
'bs_csv_col_amount' => 'Col #: Amount',
|
||||||
|
'bs_csv_col_debit' => 'Col #: Debit',
|
||||||
|
'bs_csv_col_credit' => 'Col #: Credit',
|
||||||
|
'bs_csv_col_ref' => 'Col #: Unique Reference (Optional)',
|
||||||
|
'btn_save_format' => 'Save format',
|
||||||
|
'bs_csv_saved' => 'CSV format saved',
|
||||||
|
'bs_tab_csv' => '📥 CSV Import',
|
||||||
|
'bs_csv_settings_title' => 'Format Configuration',
|
||||||
|
'bs_csv_drop_title' => 'Drop your CSV file here',
|
||||||
|
'bs_csv_drop_desc' => 'or click to browse your files',
|
||||||
|
'bs_csv_err_type' => 'Error: Only .csv files are allowed.',
|
||||||
|
'bs_csv_file_ok' => 'File loaded',
|
||||||
|
'bs_csv_preview_title' => 'Columns Preview',
|
||||||
|
'bs_csv_empty_col' => 'Empty',
|
||||||
|
'bs_csv_no_data' => 'No transaction row found in this file.',
|
||||||
|
|
||||||
|
// ONBOARDING BUDGET
|
||||||
|
'bs_onb_title' => 'Welcome! Let\'s set up your budget:',
|
||||||
|
'bs_onb_desc' => 'Complete these steps so the app can calculate your remaining budget:',
|
||||||
|
'bs_onb_cat_link' => 'Categories',
|
||||||
|
'bs_onb_cat_desc' => 'Create at least one expense category.',
|
||||||
|
'bs_onb_acc_link' => 'Bank Accounts',
|
||||||
|
'bs_onb_acc_desc' => 'Add at least one checking or savings account.',
|
||||||
|
'bs_onb_sal_link' => 'Salaries',
|
||||||
|
'bs_onb_sal_desc' => 'Enter your income for the current year.',
|
||||||
|
'bs_onb_items_link' => 'Budget Items',
|
||||||
|
'bs_onb_items_desc' => 'Go to the Summary tab to list your fixed expenses (Rent, Insurance...).',
|
||||||
|
'bs_rule_search_ph' => '🔍 Search for a keyword (e.g. WALMART)...',
|
||||||
|
'error_loading_settings' => 'Error loading settings',
|
||||||
|
'settings' => 'Settings',
|
||||||
|
|
||||||
|
// --- BUDGET: TRACKING ---
|
||||||
|
'bud_rem_school' => 'School (estimated remaining)',
|
||||||
|
'bud_rem_fmcg' => 'Groceries (estimated remaining)',
|
||||||
|
'bud_rem_fuel' => 'Fuel (estimated remaining)',
|
||||||
|
'bud_closed_archived' => 'This month is closed and archived.',
|
||||||
|
'bud_budget_of' => 'Budget:',
|
||||||
|
'bud_confirm_close' => 'Close this month? This will freeze the current balances and switch the default tracking to next month.',
|
||||||
|
'bud_close_btn' => 'Close',
|
||||||
|
'bud_confirm_reopen' => 'Reopen this month? Its balances will be recalculated live.',
|
||||||
|
'bud_reopen_btn' => 'Reopen',
|
||||||
|
'bud_active_month_btn' => 'Active month',
|
||||||
|
'bud_bank_balance' => 'Bank balance',
|
||||||
|
'bud_frozen' => 'Frozen',
|
||||||
|
'bud_current' => 'Current',
|
||||||
|
'bud_update_btn' => 'Upd.',
|
||||||
|
'bud_theoretical_balance' => 'Theoretical end-of-month balance',
|
||||||
|
'bud_upcoming_charges' => 'Upcoming charges',
|
||||||
|
'bud_all_paid' => 'All paid!',
|
||||||
|
'bud_capacity_tooltip' => 'Calculation: Previous month carry-over (%s€) + Salaries (%s€) + Other income (%s€)',
|
||||||
|
'bud_max_capacity' => 'Max capacity',
|
||||||
|
'bud_actual' => 'Actual',
|
||||||
|
'bud_theoretical' => 'Theoretical',
|
||||||
|
'bud_import_csv' => 'Import CSV',
|
||||||
|
'bud_csv_file' => 'CSV file (semicolon-separated)',
|
||||||
|
'bud_preview' => 'Preview',
|
||||||
|
'bud_validate_import' => 'Validate import',
|
||||||
|
'bud_assign_month' => 'Assign to month',
|
||||||
|
'bud_op_date' => 'Operation date',
|
||||||
|
'bud_label' => 'Label',
|
||||||
|
'bud_amount' => 'Amount',
|
||||||
|
'bud_category' => 'Category',
|
||||||
|
'bud_ignore' => 'Ignore',
|
||||||
|
'bud_to_define' => 'To define',
|
||||||
|
'bud_is_charge' => 'Fixed charge?',
|
||||||
|
'bud_is_income' => 'Income?',
|
||||||
|
'btn_import' => 'Import',
|
||||||
|
'bud_no_lines' => 'No lines.',
|
||||||
|
'bud_confirm_delete' => 'Delete this line?',
|
||||||
|
'bud_new_transaction' => 'New transaction',
|
||||||
|
'bud_assign_to_month' => 'Assign to management month',
|
||||||
|
'bud_real_date' => 'Actual operation date',
|
||||||
|
'bud_type' => 'Type',
|
||||||
|
'bud_type_expense' => 'Expense',
|
||||||
|
'bud_type_income' => 'Income / Reimbursement',
|
||||||
|
'bud_beneficiary' => 'Beneficiary',
|
||||||
|
'bud_fixed_charge' => 'Fixed charge',
|
||||||
|
'bud_expected_income' => 'Expected income',
|
||||||
|
'bud_amount_eur' => 'Amount (€)',
|
||||||
|
'bud_update_balance' => 'Update bank balance',
|
||||||
|
'bud_balance_eur' => 'Balance (€)',
|
||||||
|
'bud_add_title' => 'Add',
|
||||||
|
'bud_edit_title' => 'Edit transaction',
|
||||||
|
'bud_to_define_js' => 'to define',
|
||||||
|
'bud_bar_actual' => 'Actual',
|
||||||
|
'bud_bar_theoretical' => 'Theoretical',
|
||||||
|
'bud_bar_max_cap' => 'Max capacity',
|
||||||
|
|
||||||
|
// --- BUDGET: RECAP ---
|
||||||
|
'bud_recap_monthly_title' => 'Monthly summary',
|
||||||
|
'bud_recap_btn_add' => 'Add a cost / income',
|
||||||
|
'bud_planned_amount' => 'Planned amount',
|
||||||
|
'bud_day' => 'Day',
|
||||||
|
'bud_month_state' => 'Month status',
|
||||||
|
'bud_est_short' => 'Est.',
|
||||||
|
'bud_reg_planned_in' => 'Regularisation planned in %s',
|
||||||
|
'bud_bonus' => 'Bonus',
|
||||||
|
'bud_overrun' => 'Overrun',
|
||||||
|
'bud_remaining' => 'Remaining',
|
||||||
|
'bud_exact_amount' => 'Exact amount',
|
||||||
|
'bud_per_month_short' => 'i.e.',
|
||||||
|
'bud_month_short' => 'month',
|
||||||
|
'bud_freq_mensuel' => 'Monthly',
|
||||||
|
'bud_freq_annuel' => 'Annual',
|
||||||
|
'bud_state_validated' => 'Validated',
|
||||||
|
'bud_state_partial' => 'Partial',
|
||||||
|
'bud_state_waiting' => 'Pending',
|
||||||
|
'bud_total_income_smoothed' => 'Total income (smoothed)',
|
||||||
|
'bud_total_expenses_smoothed'=> 'Total expenses (smoothed)',
|
||||||
|
'bud_theoretical_balance_recap'=> 'Theoretical balance',
|
||||||
|
'bud_recap_footer_note' => 'Payment status updates automatically based on operations imported in the "Monthly tracking" tab.',
|
||||||
|
'bud_recap_modal_add' => 'Add an item',
|
||||||
|
'bud_recap_modal_edit' => 'Edit',
|
||||||
|
'bud_label_name' => 'Name',
|
||||||
|
'bud_ph_item_name' => 'e.g. Rent, Salary...',
|
||||||
|
'bud_label_keywords' => 'Keywords (auto-detection)',
|
||||||
|
'bud_ph_keywords' => 'e.g. NETFLIX, PRIME VIDEO (comma-separated)',
|
||||||
|
'bud_help_keywords' => 'If a monthly expense contains this word, the line will switch to "Validated".',
|
||||||
|
'bud_label_day' => 'Day (1-31)',
|
||||||
|
'bud_cat_income' => 'Savings',
|
||||||
|
'bud_cat_expense' => 'Expense (Cost)',
|
||||||
|
'bud_label_frequency' => 'Frequency',
|
||||||
|
'bud_label_amount_type' => 'Amount type',
|
||||||
|
'bud_type_fixed' => 'Fixed (Invoice)',
|
||||||
|
'bud_type_variable' => 'Variable (Estimate)',
|
||||||
|
'bud_label_regularization' => 'Regularisation',
|
||||||
|
'bud_reg_none' => 'None',
|
||||||
|
'bud_recap_confirm_delete' => 'Are you sure you want to delete this item?',
|
||||||
|
'bud_err_delete' => 'Error while deleting.',
|
||||||
|
'bud_prev_monthly_target' => 'Target amount / Monthly payment (€)',
|
||||||
|
'bud_prev_transfer_dest' => 'Transfer destination',
|
||||||
|
'bud_prev_none' => 'No destination (Standard expense)',
|
||||||
|
|
||||||
|
// --- BUDGET: SAVINGS ---
|
||||||
|
'bud_sav_add_month' => 'Enter a month',
|
||||||
|
'bud_sav_add_one_month' => '+1 Month',
|
||||||
|
'bud_sav_no_data' => 'No data for %s.',
|
||||||
|
'bud_sav_post_month' => 'Category / Month',
|
||||||
|
'bud_sav_from_date' => 'From %s',
|
||||||
|
'bud_sav_edit_modal' => 'Edit with modal',
|
||||||
|
'bud_sav_delete_month' => 'Delete entire month',
|
||||||
|
'bud_sav_total_bank' => 'Total bank',
|
||||||
|
'bud_sav_extra' => 'Extra',
|
||||||
|
'bud_sav_modal_title_add' => 'Enter a month',
|
||||||
|
'bud_sav_modal_title_edit' => 'Edit:',
|
||||||
|
'bud_sav_month_concerned' => 'Month concerned',
|
||||||
|
'bud_sav_total_bank_eur' => 'Total in bank (€)',
|
||||||
|
'bud_sav_ventilation' => 'Breakdown',
|
||||||
|
'bud_sav_adj_help' => 'Use the adjustment (+/-) to recalculate automatically.',
|
||||||
|
'bud_sav_add_line' => 'Line',
|
||||||
|
'bud_sav_current' => 'Current',
|
||||||
|
'bud_sav_adjust' => 'Adj. (+/-)',
|
||||||
|
'bud_sav_new' => 'New',
|
||||||
|
'bud_sav_sum_mode_title' => 'Enable quick addition',
|
||||||
|
'bud_sav_selection' => 'Selection:',
|
||||||
|
'bud_sav_ph_name' => 'Name (e.g. Holidays)',
|
||||||
|
'bud_sav_saving' => 'Saving...',
|
||||||
|
'bud_err_tech' => 'A technical error occurred.',
|
||||||
|
'bud_sav_confirm_delete_month' => 'Delete ALL of month %m for %o?',
|
||||||
|
'bud_sav_prompt_duplicate' => "Do you want to copy data from %s to %t1?\n\nEnter the new TOTAL bank balance (€) for %t2:",
|
||||||
|
'bud_err_server' => 'Server error: ',
|
||||||
|
'bud_err_network_dup' => 'Network error during duplication.',
|
||||||
|
'budget_tab_kids' => 'Kids 👶',
|
||||||
|
'bud_sav_add_one_month' => '+1 Month',
|
||||||
|
'bud_sav_add_month' => 'Add a month',
|
||||||
|
'bud_sav_no_data' => 'No savings data for %s.',
|
||||||
|
'bud_sav_post_month' => 'Item / Month',
|
||||||
|
'bud_sav_from_date' => 'From %s',
|
||||||
|
'bud_sav_edit_modal' => 'Edit this month',
|
||||||
|
'bud_sav_delete_month' => 'Delete this month',
|
||||||
|
'bud_sav_total_bank' => 'TOTAL BANK',
|
||||||
|
'bud_sav_extra' => 'UNALLOCATED (EXTRAS)',
|
||||||
|
'bud_sav_modal_title_add' => 'Add a month',
|
||||||
|
'bud_sav_modal_title_edit' => 'Edit',
|
||||||
|
'bud_sav_month_concerned' => 'Target month',
|
||||||
|
'bud_sav_total_bank_eur' => 'Total in Bank (€)',
|
||||||
|
'bud_sav_ventilation' => 'Accounts breakdown',
|
||||||
|
'bud_sav_adj_help' => 'Enter adjustments (+/-) to auto-calculate.',
|
||||||
|
'bud_sav_add_line' => 'Add line',
|
||||||
|
'bud_category' => 'Category',
|
||||||
|
'bud_sav_current' => 'Current',
|
||||||
|
'bud_sav_adjust' => 'Adjustment',
|
||||||
|
'bud_sav_new' => 'New',
|
||||||
|
'bud_sav_sum_mode_title' => 'Toggle calculator',
|
||||||
|
'bud_sav_selection' => 'Selection:',
|
||||||
|
'bud_sav_ph_name' => 'Account name',
|
||||||
|
|
||||||
|
// JS & Errors
|
||||||
|
'bud_sav_confirm_delete_month' => 'Are you sure you want to delete all data of %m for %o?',
|
||||||
|
'bud_sav_prompt_duplicate' => "Do you want to duplicate data from %s to %t1?\n\nEnter the new BANK TOTAL (€) for %t2:",
|
||||||
|
'bud_err_tech' => 'A technical error occurred.',
|
||||||
|
'bud_err_server' => 'Server error: ',
|
||||||
|
'bud_err_network_dup' => 'Network error during duplication.',
|
||||||
|
'bud_sav_saving' => 'Saving...',
|
||||||
|
'bud_err_delete' => 'Error during deletion.',
|
||||||
|
|
||||||
|
// --- BUDGET: FORECAST ---
|
||||||
|
'bud_prev_incomes' => 'Income',
|
||||||
|
'bud_prev_person' => 'Person',
|
||||||
|
'bud_prev_salary' => 'Salary',
|
||||||
|
'bud_prev_monthly_payment' => 'Monthly payment',
|
||||||
|
'bud_prev_joint_account' => 'Joint account',
|
||||||
|
'bud_prev_func_expenses' => 'Running costs',
|
||||||
|
'bud_prev_perso_savings' => 'Personal savings',
|
||||||
|
'bud_prev_family_savings' => 'Family savings',
|
||||||
|
'bud_prev_available' => 'Available',
|
||||||
|
'bud_prev_perso_remaining' => 'Personal remaining',
|
||||||
|
'bud_prev_budget_alloc' => 'Budget allocation',
|
||||||
|
'bud_prev_today' => 'Today',
|
||||||
|
'bud_prev_new_line' => 'New line',
|
||||||
|
'bud_prev_remaining' => 'Remaining',
|
||||||
|
'bud_prev_confirm_del_line' => 'Delete this line and all its history?',
|
||||||
|
'bud_prev_notes_for' => 'Notes for',
|
||||||
|
'bud_prev_saved' => 'Saved',
|
||||||
|
'bud_prev_notes_ph' => 'Write your remarks, reminders or comments for this month here...',
|
||||||
|
'bud_prev_save_note' => 'Save note',
|
||||||
|
'bud_prev_transfers_to_make' => 'Transfers to make',
|
||||||
|
'bud_prev_destination' => 'Destination',
|
||||||
|
'bud_prev_done' => 'DONE',
|
||||||
|
'bud_prev_validate' => 'Validate',
|
||||||
|
'bud_prev_grand_total' => 'GRAND TOTAL',
|
||||||
|
'bud_prev_new_line_title' => 'New budget line',
|
||||||
|
'bud_prev_edit_line_title' => 'Edit line',
|
||||||
|
'bud_prev_label_target' => 'Target (Destination)',
|
||||||
|
'bud_prev_choose' => 'Choose',
|
||||||
|
'bud_prev_link_holiday' => 'Link to a trip (Optional)',
|
||||||
|
'bud_prev_no_link' => 'Do not associate',
|
||||||
|
'bud_prev_err_no_history' => 'Cannot copy: no history available.',
|
||||||
|
'bud_prev_confirm_copy' => "Do you want to copy data from %s to %t?\n\n⚠️ This will overwrite all values already present for %t.",
|
||||||
|
'bud_prev_confirm_transfers' => "Confirm that %p has made all their transfers for %m?\n\nThis will automatically update Savings.",
|
||||||
|
'bud_prev_label_name' => 'Name',
|
||||||
|
'bud_adv_title' => 'Advances & Family Tricount',
|
||||||
|
'bud_adv_add' => 'Add an Advance',
|
||||||
|
'bud_adv_edit' => 'Edit Advance',
|
||||||
|
'bud_adv_who_paid' => 'Who paid?',
|
||||||
|
'bud_adv_payer' => 'Payer',
|
||||||
|
'bud_adv_has_advanced' => '%s advanced:',
|
||||||
|
'bud_adv_cc_label' => 'on the joint account',
|
||||||
|
'bud_adv_livret_label' => 'from their savings account',
|
||||||
|
'bud_adv_cc_balance_title' => 'Joint Account Balance',
|
||||||
|
'bud_adv_owed_to' => '<strong>%s €</strong> are owed to <strong>%s</strong>',
|
||||||
|
'bud_adv_balanced' => 'Accounts are perfectly balanced!',
|
||||||
|
'bud_adv_saved_badge' => 'Savings',
|
||||||
|
'bud_adv_already_saved' => 'This money comes from a savings account',
|
||||||
|
'bud_adv_ph_desc' => 'e.g., Groceries, Electricity bill...',
|
||||||
|
'bud_adv_confirm_resolve' => 'Confirm the complete reimbursement of this advance?',
|
||||||
|
'bud_adv_confirm_delete' => 'Permanently delete this advance?',
|
||||||
|
'date' => 'Date',
|
||||||
|
'bud_label_name' => 'Description',
|
||||||
|
'bud_amount' => 'Amount',
|
||||||
|
'actions' => 'Actions',
|
||||||
|
'edit' => 'Edit',
|
||||||
|
'delete' => 'Delete',
|
||||||
|
'btn_cancel' => 'Cancel',
|
||||||
|
'btn_save' => 'Save',
|
||||||
|
'bud_prev_target_ph' => 'E.g., 150',
|
||||||
|
'bud_prev_transfer_to' => 'To',
|
||||||
|
|
||||||
|
// --- Module Budget : Provisions & Optimisation ---
|
||||||
|
'budget_provisions_title' => 'Provisions & Cashflow Optimization',
|
||||||
|
'btn_optimize_cashflow' => 'Optimize My Savings',
|
||||||
|
'add_new_provision' => 'Anticipate an upcoming major expense',
|
||||||
|
'provision_label' => 'Expense description',
|
||||||
|
'provision_placeholder_wood' => 'E.g., Firewood order, Car service...',
|
||||||
|
'amount' => 'Amount',
|
||||||
|
'expected_date' => 'Expected date',
|
||||||
|
'btn_add' => 'Add',
|
||||||
|
'upcoming_provisions_list' => 'Scheduled expenses',
|
||||||
|
'optimization_assistant_title' => 'Fortnightly Interest Optimization Assistant',
|
||||||
|
'error_generic' => 'An error occurred',
|
||||||
|
'success_add_provision' => 'Forecasted expense added successfully!',
|
||||||
|
'no_provisions' => 'No forecasted expenses registered at the moment.',
|
||||||
|
'btn_delete' => 'Delete',
|
||||||
|
'savings_this_month' => 'Total savings to allocate this month',
|
||||||
|
'calculate' => 'Calculate optimal strategy',
|
||||||
|
'date_past_warning' => 'The selected date has already passed!',
|
||||||
|
'budget_tab_provisions' => 'Forecasts',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MODULE: GIFTS (gift-list)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// 1. Titles and headers
|
||||||
|
'gift_page_title' => 'Gift List',
|
||||||
|
'gift_main_title' => 'Gifts of %s', // %s will be replaced by the year (e.g., Gifts of 2026)
|
||||||
|
'settings' => 'Settings',
|
||||||
|
|
||||||
|
// 2. Filters
|
||||||
|
'gift_filter_all_children' => 'All children',
|
||||||
|
'gift_filter_all_adults' => 'All adults',
|
||||||
|
|
||||||
|
// 3. Content and empty states
|
||||||
|
'gift_add_gift' => 'Add',
|
||||||
|
'gift_empty_state_no_gifts' => 'No gifts recorded for this occasion.',
|
||||||
|
'gift_empty_state_no_filter' => 'No gifts match your filters.',
|
||||||
|
'gift_paid_by' => 'Paid by %s', // %s will be replaced by the payer's name
|
||||||
|
|
||||||
|
// 4. Tricount Section (Balance & Refunds)
|
||||||
|
'gift_liquidations' => 'Balance & Refunds',
|
||||||
|
'gift_no_debt' => 'Everything is settled, no debts!',
|
||||||
|
'gift_owes' => 'owes',
|
||||||
|
'gift_view_matrix' => 'View detailed matrix',
|
||||||
|
'gift_debtor' => 'Debtor',
|
||||||
|
'gift_creditor' => 'Creditor',
|
||||||
|
|
||||||
|
// 5. Modal: Add/Edit Gift
|
||||||
|
'gift_col_adult' => 'On behalf of',
|
||||||
|
'gift_modal_payer' => 'Paid by',
|
||||||
|
'gift_modal_gift_name' => 'Gift description',
|
||||||
|
'gift_modal_price' => 'Estimated / paid price (€)',
|
||||||
|
'gift_modal_link' => 'Product link (URL)',
|
||||||
|
'gift_modal_title_add' => 'Add for %s', // JS: %s = child's name
|
||||||
|
'gift_modal_title_edit' => 'Edit gift', // JS
|
||||||
|
'gift_confirm_delete' => 'Are you sure you want to delete this gift?', // JS
|
||||||
|
|
||||||
|
// 6. Modal: Occasions Settings
|
||||||
|
'gift_settings_title' => 'Occasions Settings',
|
||||||
|
'gift_settings_add_title' => '+ Add an occasion',
|
||||||
|
'gift_settings_name_placeholder' => 'Name (e.g. Valentine\'s Day)',
|
||||||
|
'gift_settings_date_placeholder' => 'MM-DD',
|
||||||
|
'gift_settings_date_help' => 'Date (MM-DD) is optional. Leave empty for a floating date.',
|
||||||
|
'gift_settings_empty' => 'No occasion configured.', // JS
|
||||||
|
'gift_settings_active' => 'Active', // JS
|
||||||
|
|
||||||
|
// 7. Generic buttons
|
||||||
|
'btn_cancel' => 'Cancel',
|
||||||
|
'btn_save' => 'Save',
|
||||||
|
'btn_add' => 'Add',
|
||||||
|
'loading' => 'Loading...',
|
||||||
|
|
||||||
|
// --- MODULE CADEAUX : SETTINGS ---
|
||||||
|
'gift_settings' => 'Settings',
|
||||||
|
'gift_tab_occasions' => 'Occasions & Events',
|
||||||
|
'gift_tab_proches' => 'Relatives (Outside household)',
|
||||||
|
'gift_settings_icon' => 'Icon',
|
||||||
|
'gift_settings_date' => 'Date (MM-DD)',
|
||||||
|
'gift_settings_add_occ' => 'Add an occasion',
|
||||||
|
'gift_settings_add_proche'=> 'Add a relative',
|
||||||
|
'gift_role_proche_adulte'=> 'Adult (Uncle, friend...)',
|
||||||
|
'gift_role_proche_enfant'=> 'Child (Nephew, niece...)',
|
||||||
|
'gift_ph_proche_name' => 'First name',
|
||||||
|
'gift_role_label' => 'Role',
|
||||||
|
'gift_settings_saved' => 'Settings saved!',
|
||||||
|
'gift_settings_edit_occ' => 'Edit occasion',
|
||||||
|
'gift_settings_edit_proche' => 'Edit relative',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// GARAGE MANAGER
|
||||||
|
// ==========================================
|
||||||
|
'menu_garage' => 'Garage',
|
||||||
|
'garage_page_title' => 'HouseHub - Garage',
|
||||||
|
'garage_stat_vehicles' => 'Vehicles',
|
||||||
|
'garage_stat_maintenances' => 'Services',
|
||||||
|
'garage_stat_parts' => 'Parts',
|
||||||
|
'garage_stat_cost' => 'Total cost',
|
||||||
|
'garage_my_vehicles' => 'My vehicles',
|
||||||
|
'garage_reminders' => 'Reminders & upcoming services',
|
||||||
|
'garage_vehicles_title' => 'My vehicles',
|
||||||
|
'garage_all_parts' => 'All parts',
|
||||||
|
'garage_parts_count' => 'parts',
|
||||||
|
'garage_maintenances' => 'Services',
|
||||||
|
'garage_parts' => 'Parts',
|
||||||
|
'garage_add' => 'Add',
|
||||||
|
'garage_add_vehicle' => 'Add a vehicle',
|
||||||
|
'garage_add_maintenance' => 'Add a service',
|
||||||
|
'garage_add_part' => 'Add a part',
|
||||||
|
'garage_vehicle_name' => 'Vehicle name',
|
||||||
|
'garage_brand' => 'Make',
|
||||||
|
'garage_model' => 'Model',
|
||||||
|
'garage_year' => 'Year',
|
||||||
|
'garage_plate' => 'Licence plate',
|
||||||
|
'garage_fuel' => 'Fuel type',
|
||||||
|
'garage_km' => 'Current mileage',
|
||||||
|
'garage_color' => 'Colour',
|
||||||
|
'garage_purchase_date' => 'Purchase date',
|
||||||
|
'garage_purchase_price' => 'Purchase price (€)',
|
||||||
|
'garage_photo' => 'Photo',
|
||||||
|
'garage_notes' => 'Notes',
|
||||||
|
'garage_maint_type' => 'Service type',
|
||||||
|
'garage_date' => 'Date',
|
||||||
|
'garage_km_at' => 'Mileage at the time',
|
||||||
|
'garage_labor_cost' => 'Labour cost (€)',
|
||||||
|
'garage_description' => 'Description',
|
||||||
|
'garage_mechanic' => 'Mechanic',
|
||||||
|
'garage_garage' => 'Garage / Workshop',
|
||||||
|
'garage_next_reminder' => 'Next service (reminder)',
|
||||||
|
'garage_next_date' => 'Planned date',
|
||||||
|
'garage_next_km' => 'Planned mileage',
|
||||||
|
'garage_part_name' => 'Part name',
|
||||||
|
'garage_category' => 'Category',
|
||||||
|
'garage_reference' => 'Reference',
|
||||||
|
'garage_unit_price' => 'Unit price (€)',
|
||||||
|
'garage_quantity' => 'Quantity',
|
||||||
|
'garage_unit' => 'Unit',
|
||||||
|
'garage_supplier' => 'Supplier',
|
||||||
|
'garage_change_photo' => 'Change photo',
|
||||||
|
'garage_new_photo' => 'New photo',
|
||||||
|
'garage_update_photo' => 'Update',
|
||||||
|
];
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
<?php
|
||||||
|
// Dictionnaire Français
|
||||||
|
return [
|
||||||
|
// ==========================================
|
||||||
|
// TEST AUTO
|
||||||
|
// ==========================================
|
||||||
|
'test_running' => 'Tests en cours...',
|
||||||
|
'test_passed' => 'Test réussi',
|
||||||
|
'test_failed' => 'Test échoué',
|
||||||
|
'tests_title' => 'Laboratoire de Tests',
|
||||||
|
'tests_run_budget' => 'Tester le Budget',
|
||||||
|
'tests_run_all' => 'Lancer tous les tests',
|
||||||
|
'tests_copy_report' => 'Copier le rapport',
|
||||||
|
'tests_waiting' => 'En attente du lancement...',
|
||||||
|
'tests_report_copied' => 'Rapport copié dans le presse-papier !',
|
||||||
|
'tests_live_preview' => 'Aperçu en direct',
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// GLOBAL & NAVIGATION
|
||||||
|
// ==========================================
|
||||||
|
'menu_home' => 'Accueil',
|
||||||
|
'menu_calendar' => 'Calendrier',
|
||||||
|
'menu_budget' => 'Budget',
|
||||||
|
'menu_holidays' => 'Voyages',
|
||||||
|
'menu_gifts' => 'Cadeaux',
|
||||||
|
'menu_calendar_ios' => 'Calendrier iOS',
|
||||||
|
'nav_home' => 'Accueil',
|
||||||
|
'nav_calendar' => 'Calendrier',
|
||||||
|
'nav_budget' => 'Budget',
|
||||||
|
'nav_holidays' => 'Voyages',
|
||||||
|
'nav_gifts' => 'Cadeaux',
|
||||||
|
'aria_menu' => 'Menu mobile',
|
||||||
|
|
||||||
|
|
||||||
|
// Boutons et actions globales
|
||||||
|
'actions' => 'Actions',
|
||||||
|
'edit' => 'Modifier',
|
||||||
|
'delete' => 'Supprimer',
|
||||||
|
'save' => 'Enregistrer',
|
||||||
|
'cancel' => 'Annuler',
|
||||||
|
'btn_save' => 'Enregistrer',
|
||||||
|
'btn_cancel' => 'Annuler',
|
||||||
|
'btn_delete' => 'Supprimer',
|
||||||
|
'btn_close' => 'Fermer',
|
||||||
|
'btn_back' => '◀ Retour',
|
||||||
|
'btn_edit_bases' => '⚙️ Modifier les bases',
|
||||||
|
'btn_edit' => 'Modifier',
|
||||||
|
'error_occured' => 'Une erreur est survenue.',
|
||||||
|
|
||||||
|
// Mots génériques & Erreurs
|
||||||
|
'error' => 'Erreur.',
|
||||||
|
'confirm_delete' => 'Es-tu sûr de vouloir supprimer cet élément ?',
|
||||||
|
|
||||||
|
// Mois & Jours
|
||||||
|
'month_01' => 'Janvier', 'month_02' => 'Février', 'month_03' => 'Mars',
|
||||||
|
'month_04' => 'Avril', 'month_05' => 'Mai', 'month_06' => 'Juin',
|
||||||
|
'month_07' => 'Juillet', 'month_08' => 'Août', 'month_09' => 'Septembre',
|
||||||
|
'month_10' => 'Octobre', 'month_11' => 'Novembre','month_12' => 'Décembre',
|
||||||
|
'day_mon' => 'Lun', 'day_tue' => 'Mar', 'day_wed' => 'Mer',
|
||||||
|
'day_thu' => 'Jeu', 'day_fri' => 'Ven', 'day_sat' => 'Sam', 'day_sun' => 'Dim',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// AUTHENTIFICATION / LOGIN
|
||||||
|
// ==========================================
|
||||||
|
'login_title' => 'Connexion - HouseHub',
|
||||||
|
'login_header' => 'Connexion',
|
||||||
|
'label_username' => 'Identifiant',
|
||||||
|
'label_password' => 'Mot de passe',
|
||||||
|
'placeholder_username' => 'ex: pacha',
|
||||||
|
'btn_login' => 'Connexion',
|
||||||
|
'btn_login_submit' => 'Se connecter',
|
||||||
|
'logout' => 'Déconnexion',
|
||||||
|
'btn_logout' => 'Déconnexion',
|
||||||
|
'error_missing_fields' => 'Champs obligatoires.',
|
||||||
|
'error_invalid_credentials' => 'Identifiants incorrects.',
|
||||||
|
'login_subtitle' => 'Veuillez vous identifier pour continuer',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ACCUEIL / INDEX
|
||||||
|
// ==========================================
|
||||||
|
'home_title' => 'HouseHub - Accueil',
|
||||||
|
'home_welcome' => 'Bienvenue sur HouseHub',
|
||||||
|
'home_subtitle' => "Centre de contrôle de l'organisation familiale.",
|
||||||
|
'home_logged_as' => 'Connecté en tant que',
|
||||||
|
'home_modules_title' => 'Modules',
|
||||||
|
'mod_calendar_name' => 'Calendrier Familial',
|
||||||
|
'mod_calendar_desc' => "Gérez les congés, les modes de garde et l'organisation hebdomadaire.",
|
||||||
|
'mod_holidays_name' => 'Vacances',
|
||||||
|
'mod_holidays_desc' => 'Planifiez vos prochaines escapades, destinations et idées de voyage.',
|
||||||
|
'mod_gifts_name' => 'Cadeaux',
|
||||||
|
'mod_gifts_desc' => 'Planifiez les cadeaux pour chaque enfant, pour Noël et les anniversaires.',
|
||||||
|
'mod_budget_name' => 'Budget',
|
||||||
|
'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.',
|
||||||
|
'mod_garage_name' => 'Garage',
|
||||||
|
'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.',
|
||||||
|
'mod_memo_name' => 'Notes',
|
||||||
|
'mod_memo_desc' => 'Vos mémos, recettes, instructions et infos diverses avec Markdown et pièces jointes.',
|
||||||
|
'menu_memo' => 'Notes',
|
||||||
|
'mod_todo_name' => 'Todo',
|
||||||
|
'mod_todo_desc' => 'Gérez vos tâches, listes de courses et rappels en famille.',
|
||||||
|
'mod_calendar_ios_name' => 'Calendrier iOS',
|
||||||
|
'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).',
|
||||||
|
'menu_todo' => 'Todo',
|
||||||
|
'mod_groceries_name' => 'Courses',
|
||||||
|
'mod_groceries_desc' => 'Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.',
|
||||||
|
'menu_groceries' => 'Courses',
|
||||||
|
// Module Liste (remplace Courses)
|
||||||
|
'mod_liste_name' => 'Listes',
|
||||||
|
'mod_liste_desc' => 'Listes partagées nommées — courses, bricolage, pharmacie… Autant de listes que nécessaire.',
|
||||||
|
'menu_liste' => 'Listes',
|
||||||
|
'liste_placeholder_add' => 'Ajouter un article…',
|
||||||
|
'liste_btn_add' => 'Ajouter',
|
||||||
|
'liste_btn_uncheck_all' => 'Tout décocher',
|
||||||
|
'liste_btn_delete_picked' => 'Supprimer les cochés',
|
||||||
|
'liste_btn_clear_all' => 'Vider la liste',
|
||||||
|
'liste_subtitle' => '{pending} à faire · {cart} cochés',
|
||||||
|
'liste_added' => 'Ajouté',
|
||||||
|
'liste_updated' => 'Modifié',
|
||||||
|
'liste_deleted' => 'Supprimé',
|
||||||
|
'liste_already_in' => 'Déjà dans la liste',
|
||||||
|
'liste_confirm_clear' => 'Vider toute la liste ?',
|
||||||
|
'liste_confirm_delete_list' => 'Supprimer cette liste et tous ses articles ?',
|
||||||
|
'liste_history_title' => 'Récemment utilisés — clic pour rajouter',
|
||||||
|
'liste_new_list' => 'Nouvelle liste',
|
||||||
|
'liste_list_name_placeholder'=> 'Nom de la liste…',
|
||||||
|
'liste_create' => 'Créer',
|
||||||
|
'liste_rename_list' => 'Renommer',
|
||||||
|
'liste_new_name' => 'Nouveau nom :',
|
||||||
|
'liste_list_deleted' => 'Liste supprimée',
|
||||||
|
'liste_list_created' => 'Liste créée',
|
||||||
|
'liste_list_renamed' => 'Liste renommée',
|
||||||
|
'liste_edit_placeholder' => 'Modifier l\'article :',
|
||||||
|
'liste_empty_state' => 'Créez votre première liste avec le bouton + ci-dessus.',
|
||||||
|
'liste_settings_title' => 'Listes — historique',
|
||||||
|
'liste_settings_intro' => 'Nombre maximum d\'articles mémorisés dans l\'historique (commun à toutes les listes).',
|
||||||
|
'liste_settings_history_max' => 'Taille de l\'historique',
|
||||||
|
'liste_settings_history_hint'=> 'Entre 1 et 50.',
|
||||||
|
'groceries_settings_saved' => 'Préférences enregistrées.',
|
||||||
|
'groceries_settings_error' => 'Impossible d\'enregistrer :',
|
||||||
|
'groceries_err_duplicate' => 'Ce produit est déjà dans la liste.',
|
||||||
|
'cta_open' => 'Ouvrir',
|
||||||
|
'cta_explore' => 'Explorer',
|
||||||
|
'cta_view_lists' => 'Déballer',
|
||||||
|
'cta_manage' => 'Gérer',
|
||||||
|
'cta_service' => 'Réviser',
|
||||||
|
'cta_jot' => 'Griffonner',
|
||||||
|
'cta_check' => 'Cocher',
|
||||||
|
'cta_cart' => 'Faire les courses',
|
||||||
|
'cta_sync' => 'Synchroniser',
|
||||||
|
'mod_printvault_name' => 'PrintVault',
|
||||||
|
'mod_printvault_desc' => 'Gérez vos fichiers d\'impression 3D — STL, 3MF, GCode avec viewer interactif.',
|
||||||
|
'menu_printvault' => 'PrintVault',
|
||||||
|
'cta_print' => 'Imprimer',
|
||||||
|
'mod_planka_name' => 'Planka',
|
||||||
|
'mod_planka_desc' => 'Gérez vos projets en mode Kanban avec Planka.',
|
||||||
|
'menu_planka' => 'Planka',
|
||||||
|
|
||||||
|
// --- SETTINGS : ENFANTS ---
|
||||||
|
'set_kids_title' => 'Enfants du foyer',
|
||||||
|
'set_kids_desc' => 'Gérez la liste des enfants pour les associer à l\'épargne, aux cadeaux, etc.',
|
||||||
|
'set_add_kid_placeholder' => 'Prénom de l\'enfant',
|
||||||
|
'set_btn_add_kid' => 'Ajouter',
|
||||||
|
'set_err_kid_name' => 'Le prénom est requis.',
|
||||||
|
'set_confirm_del_kid' => 'Supprimer cet enfant ? (Son historique financier restera visible dans les archives).',
|
||||||
|
'set_no_kids' => 'Aucun enfant configuré pour ce foyer.',
|
||||||
|
'set_family_config' => 'Configuration du foyer',
|
||||||
|
'set_global_params' => 'Paramètres globaux',
|
||||||
|
'set_care_modes' => 'Modes de garde usuels',
|
||||||
|
'set_add_care_mode' => 'Ajouter un mode',
|
||||||
|
'btn_save_config' => 'Enregistrer',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// FAMILY CALENDAR
|
||||||
|
// ==========================================
|
||||||
|
'fc_page_title' => 'HouseHub - Calendrier',
|
||||||
|
'fc_main_header' => 'Calendrier Familial',
|
||||||
|
'fc_btn_correct_balances' => 'Corriger les soldes',
|
||||||
|
'fc_btn_school_holidays' => 'Vacances scolaires',
|
||||||
|
'fc_modal_holidays_title' => 'Vacances Scolaires',
|
||||||
|
'fc_modal_snap_title' => 'Ajuster un solde',
|
||||||
|
'fc_col_period' => 'Période',
|
||||||
|
'fc_col_from' => 'Du',
|
||||||
|
'fc_col_to' => 'Au',
|
||||||
|
'fc_label_person' => 'Personne',
|
||||||
|
'fc_label_leave_type' => 'Type de congé',
|
||||||
|
'fc_label_apply_date' => "Date d'application",
|
||||||
|
'fc_snap_date_hint' => "Choisissez de préférence le 1er jour d'un mois",
|
||||||
|
'fc_label_remaining_balance'=> 'Solde restant à cette date',
|
||||||
|
'fc_placeholder_balance' => 'ex: 12.5',
|
||||||
|
'fc_btn_save_snap' => 'Enregistrer le correctif',
|
||||||
|
'fc_view_1m' => '1 mois',
|
||||||
|
'fc_view_2m' => '2 mois',
|
||||||
|
'fc_view_3m' => '3 mois',
|
||||||
|
'fc_view_year' => 'Année',
|
||||||
|
'fc_weekly_planning' => 'Planning hebdomadaire',
|
||||||
|
'fc_col_month' => 'Mois',
|
||||||
|
'fc_col_week_short' => 'Sem.',
|
||||||
|
'fc_col_av' => 'Av.',
|
||||||
|
'fc_col_use' => 'Use',
|
||||||
|
'fc_legend_title' => 'Légende',
|
||||||
|
'fc_summary_title' => 'Récapitulatif',
|
||||||
|
'fc_summ_year' => 'Année',
|
||||||
|
'fc_summ_month' => 'Mois',
|
||||||
|
'fc_school_year' => 'Année scolaire',
|
||||||
|
'fc_unit_days' => 'jours',
|
||||||
|
'fc_menu_carole' => 'Congés Carole',
|
||||||
|
'fc_clear' => 'Effacer',
|
||||||
|
'fc_menu_kids_leaves' => 'Congés Enfants',
|
||||||
|
'fc_err_save_snap' => 'Erreur lors de la sauvegarde du correctif.',
|
||||||
|
'fc_err_fetch_gov' => 'Erreur lors de la connexion à l\'API gouvernementale.',
|
||||||
|
'fc_err_no_data_gov' => 'Aucune donnée trouvée sur l\'API du gouvernement pour cette année.',
|
||||||
|
'fc_err_action' => 'Erreur lors de l\'action : ',
|
||||||
|
'fc_today_short' => 'Auj.',
|
||||||
|
'fc_today_title' => "Aujourd'hui",
|
||||||
|
'care_nounou' => 'Nounou',
|
||||||
|
'care_centre' => 'Centre de loisirs',
|
||||||
|
'care_avis' => 'Avis',
|
||||||
|
'fc_tab_foyer' => '🏡 Foyer',
|
||||||
|
'fc_tab_members' => '👥 Membres & Droits',
|
||||||
|
'fc_select_member' => 'Sélectionner un membre de la famille :',
|
||||||
|
'fc_role_adult' => 'Adulte',
|
||||||
|
'fc_role_child' => 'Enfant',
|
||||||
|
'fc_care_modes_title' => 'Modes de garde associés',
|
||||||
|
'fc_care_modes_desc' => 'Cochez les modes de garde qui concernent cet enfant.',
|
||||||
|
'fc_no_care_mode' => 'Aucun mode configuré.',
|
||||||
|
'fc_matrix_title' => '👨👩👦 Matrice des Droits aux Congés',
|
||||||
|
'fc_matrix_desc' => 'Déclarez les codes de compteurs et dates de renouvellement.',
|
||||||
|
'fc_matrix_code' => 'Code',
|
||||||
|
'fc_matrix_date' => 'Date Reset',
|
||||||
|
'fc_matrix_empty' => 'Aucun compteur configuré.',
|
||||||
|
'fc_matrix_saved' => 'Matrice des droits mise à jour ! 💼',
|
||||||
|
'fc_child_saved' => 'Abonnement enfant enregistré !',
|
||||||
|
'btn_add_counter' => '➕ Ajouter',
|
||||||
|
'btn_save_rights' => '💾 Enregistrer',
|
||||||
|
'fc_modal_settings_title' => 'Paramètres du Calendrier',
|
||||||
|
'fc_zone_label' => 'Zone Scolaire (Vacances)',
|
||||||
|
'fc_zone_desc' => 'Détermine le calendrier automatique des vacances scolaires.',
|
||||||
|
'fc_care_modes_label' => 'Modes de Garde du Foyer',
|
||||||
|
'fc_care_modes_desc' => 'Gérez la liste globale des modes de garde possibles pour la famille.',
|
||||||
|
'fc_add_mode_placeholder' => 'Ex: Nounou, Casal, Grands-parents...',
|
||||||
|
'fc_settings_updated' => 'Paramètres mis à jour avec succès !',
|
||||||
|
'fc_first_day' => 'Premier jour de la semaine',
|
||||||
|
// --- CALENDRIER : PARAMÈTRES (MEMBRES & AFFICHAGE) ---
|
||||||
|
'fc_tab_display' => '🖥️ Affichage',
|
||||||
|
'fc_display_title' => 'Paramètres d\'affichage',
|
||||||
|
'fc_display_desc' => 'Personnalisez l\'apparence et le comportement par défaut de votre calendrier.',
|
||||||
|
'fc_default_view' => 'Vue par défaut',
|
||||||
|
'fc_view_month' => '1 mois',
|
||||||
|
'fc_view_2months' => '2 mois',
|
||||||
|
'fc_view_3months' => '3 mois',
|
||||||
|
'fc_working_hours' => 'Horaires de garde (Défaut)',
|
||||||
|
'fc_working_hours_desc' => 'Exemple : 08:30-18:00',
|
||||||
|
|
||||||
|
'fc_role_unknown' => 'Inconnu',
|
||||||
|
'fc_role_adult' => 'Adulte',
|
||||||
|
'fc_role_child' => 'Enfant',
|
||||||
|
'fc_role_helper' => 'Intervenant',
|
||||||
|
'fc_error' => 'Erreur : ',
|
||||||
|
|
||||||
|
'fc_care_modes_title' => 'Modes de Garde Par Défaut',
|
||||||
|
'fc_care_modes_desc' => 'Quels modes de garde s\'appliquent à cet enfant ?',
|
||||||
|
'fc_no_care_mode' => 'Aucun mode de garde configuré dans le foyer.',
|
||||||
|
'fc_child_saved' => 'Sauvegardé !',
|
||||||
|
|
||||||
|
'fc_matrix_title' => 'Matrice des Congés',
|
||||||
|
'fc_matrix_desc' => 'Définissez les quotas et la date de renouvellement.',
|
||||||
|
'fc_matrix_empty' => 'Aucun compteur défini.',
|
||||||
|
'fc_matrix_saved' => 'Matrice enregistrée !',
|
||||||
|
|
||||||
|
'fc_leave_obsolete' => 'Obsolète',
|
||||||
|
'fc_leave_code_placeholder' => 'Code...',
|
||||||
|
'fc_leave_method_fixed' => 'Fixe',
|
||||||
|
'fc_leave_method_accumulated' => 'Cumul',
|
||||||
|
|
||||||
|
'fc_date_format_placeholder' => 'JJ/MM',
|
||||||
|
'fc_date_format_title' => 'Format JJ/MM',
|
||||||
|
|
||||||
|
'fc_table_code' => 'Code',
|
||||||
|
'fc_table_method' => 'Méthode',
|
||||||
|
'fc_table_quota' => 'Quota',
|
||||||
|
'fc_table_renewal' => 'Renouv.',
|
||||||
|
|
||||||
|
'btn_save_rights' => 'Enregistrer',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MODALE PARAMÈTRES CALENDRIER
|
||||||
|
// ==========================================
|
||||||
|
'fc_modal_settings_title' => 'Paramètres du calendrier',
|
||||||
|
'fc_tab_foyer' => 'Foyer',
|
||||||
|
'fc_tab_members' => 'Membres & Droits',
|
||||||
|
|
||||||
|
// Foyer : Zone scolaire
|
||||||
|
'fc_zone_label' => 'Zone scolaire',
|
||||||
|
'fc_zone_a' => 'Zone A',
|
||||||
|
'fc_zone_b' => 'Zone B',
|
||||||
|
'fc_zone_c' => 'Zone C',
|
||||||
|
'fc_zone_other' => 'Autre / Hors France',
|
||||||
|
'fc_zone_desc' => 'Permet d\'afficher correctement les vacances scolaires.',
|
||||||
|
|
||||||
|
// Foyer : Modes de garde
|
||||||
|
'fc_care_modes_label' => 'Modes de garde',
|
||||||
|
'fc_add_mode_placeholder' => 'Ex: Nounou, Crèche...',
|
||||||
|
'fc_care_modes_desc' => 'Ajoutez les différents modes de garde pour vos enfants.',
|
||||||
|
'fc_empty_kids_msg' => 'Aucun enfant n\'est configuré dans votre famille pour le moment.',
|
||||||
|
'fc_link_settings' => 'Aller aux paramètres généraux',
|
||||||
|
|
||||||
|
// Foyer : Catalogue des congés
|
||||||
|
'fc_leave_catalog_title' => 'Catalogue des congés',
|
||||||
|
'fc_leave_catalog_desc' => 'Définissez les types de congés possibles pour toute la famille.',
|
||||||
|
'fc_add_leave_type' => 'Ajouter un type de congé',
|
||||||
|
'fc_col_code' => 'Code',
|
||||||
|
'fc_code_irreversible' => 'Irréversible',
|
||||||
|
'fc_col_label' => 'Label',
|
||||||
|
'fc_leave_label_ph' => 'Ex: RTT, Récup...',
|
||||||
|
'fc_quota_default' => 'Quota par défaut',
|
||||||
|
'fc_reset_month' => 'Mois de renouv.',
|
||||||
|
|
||||||
|
// Membres
|
||||||
|
'fc_select_member' => 'Sélectionner un membre :',
|
||||||
|
|
||||||
|
// Mois (S'ils n'existent pas déjà dans ton système)
|
||||||
|
'month_jan' => 'Janvier',
|
||||||
|
'month_feb' => 'Février',
|
||||||
|
'month_mar' => 'Mars',
|
||||||
|
'month_apr' => 'Avril',
|
||||||
|
'month_may' => 'Mai',
|
||||||
|
'month_jun' => 'Juin',
|
||||||
|
'month_jul' => 'Juillet',
|
||||||
|
'month_aug' => 'Août',
|
||||||
|
'month_sep' => 'Septembre',
|
||||||
|
'month_oct' => 'Octobre',
|
||||||
|
'month_nov' => 'Novembre',
|
||||||
|
'month_dec' => 'Décembre',
|
||||||
|
|
||||||
|
|
||||||
|
'leg_presence' => 'Présence Pep',
|
||||||
|
'leg_school_holidays' => 'Vacances Scolaires',
|
||||||
|
'leg_public_holiday' => 'Férié',
|
||||||
|
'leg_off_carole' => 'Off Carole',
|
||||||
|
'leg_extra_off' => 'Extra Off',
|
||||||
|
'leg_centre' => 'Centre',
|
||||||
|
'leg_avis' => 'Avis',
|
||||||
|
'leg_pep_sick' => 'Pep Malade',
|
||||||
|
|
||||||
|
'vac_toussaint' => 'Vacances de la Toussaint',
|
||||||
|
'vac_noel' => 'Vacances de Noël',
|
||||||
|
'vac_hiver' => "Vacances d'Hiver",
|
||||||
|
'vac_printemps' => 'Vacances de Printemps',
|
||||||
|
'vac_ascension' => 'Pont de l\'Ascension',
|
||||||
|
'vac_ete' => "Vacances d'Été",
|
||||||
|
'btn_off' => 'Off',
|
||||||
|
'btn_extra' => 'Extra',
|
||||||
|
|
||||||
|
'fc_alert_burn_days' => '⚠️ Risque de perte : %s j à poser avant le %s',
|
||||||
|
'fc_alert_burn_jra' => '⚠️ JRA : Seuls 2j sont reportables après février !',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HOLIDAYS (VOYAGES)
|
||||||
|
// ==========================================
|
||||||
|
'hdl_title_list' => 'HouseHub - Mes Vacances',
|
||||||
|
'hdl_title_detail' => 'HouseHub - Détail du voyage',
|
||||||
|
'hdl_main_title' => 'Mes Vacances',
|
||||||
|
'hdl_filter_all' => 'Tout afficher',
|
||||||
|
'hdl_filter_year' => 'Année',
|
||||||
|
'hdl_left_to_pay_label' => 'Reste à payer',
|
||||||
|
'hdl_btn_create' => 'Créer un voyage',
|
||||||
|
'hdl_no_active_trips' => 'Aucun voyage en cours pour cette période. Planifions quelque chose !',
|
||||||
|
'hdl_history_title' => 'Historique',
|
||||||
|
'hdl_dates_to_define' => 'Dates à définir',
|
||||||
|
'hdl_total_budget' => 'Budget Total',
|
||||||
|
'hdl_paid' => 'Payé',
|
||||||
|
'hdl_saved' => 'Financé',
|
||||||
|
'hdl_left_to_pay' => 'Reste à payer',
|
||||||
|
'hdl_status_booked' => 'Confirmé',
|
||||||
|
'hdl_status_planned' => 'Planifié',
|
||||||
|
'hdl_status_draft' => 'Brouillon',
|
||||||
|
'hdl_status_passed' => 'Terminé',
|
||||||
|
'hdl_status_archived' => 'Archivé',
|
||||||
|
'hdl_modal_title' => 'Planifier le voyage',
|
||||||
|
'hdl_label_name' => 'Nom du voyage',
|
||||||
|
'hdl_ph_name' => 'Ex: Octobre - Portugal',
|
||||||
|
'hdl_label_status' => 'Statut',
|
||||||
|
'hdl_label_period' => 'Période (Texte libre)',
|
||||||
|
'hdl_ph_period' => 'Ex: Octobre 2026',
|
||||||
|
'hdl_label_from' => 'Du',
|
||||||
|
'hdl_label_to' => 'Au',
|
||||||
|
'hdl_cat_transport' => 'Transport',
|
||||||
|
'hdl_add_transport' => 'Ajouter un transport',
|
||||||
|
'hdl_cat_accommodation' => 'Hébergement',
|
||||||
|
'hdl_add_accommodation' => 'Ajouter un hébergement',
|
||||||
|
'hdl_cat_activity' => 'Activité',
|
||||||
|
'hdl_add_activity' => 'Ajouter une activité',
|
||||||
|
'hdl_label_budget_food' => 'Budget Food & Bev (€)',
|
||||||
|
'hdl_label_budget_extras' => 'Budget Extras (€)',
|
||||||
|
'hdl_label_notes' => 'Notes',
|
||||||
|
'hdl_ph_notes' => 'Idées en vrac...',
|
||||||
|
'hdl_label_budget_food_extras' => 'Budget Food & Extras',
|
||||||
|
'hdl_map_itinerary' => 'Itinéraire',
|
||||||
|
'hdl_outbound' => 'Aller',
|
||||||
|
'hdl_return' => 'Retour',
|
||||||
|
'hdl_btn_add_step' => 'Ajouter une étape',
|
||||||
|
'hdl_step_details' => 'Détails du roadtrip',
|
||||||
|
'hdl_no_steps' => 'Aucune étape définie. Commencez par en ajouter une !',
|
||||||
|
'hdl_general_costs' => 'Frais généraux & Budgets',
|
||||||
|
'hdl_food_bev' => 'Food & Bev',
|
||||||
|
'hdl_extras' => 'Extras',
|
||||||
|
'hdl_view_planning' => 'Voir le planning',
|
||||||
|
'hdl_passing_point' => 'Point de passage (sans frais)',
|
||||||
|
'hdl_search_location' => 'Chercher un lieu',
|
||||||
|
'hdl_ph_search' => 'Ville, hôtel, adresse...',
|
||||||
|
'hdl_label_step_name' => "Nom de l'étape",
|
||||||
|
'hdl_label_arrival' => 'Arrivée',
|
||||||
|
'hdl_label_departure' => 'Départ',
|
||||||
|
'hdl_planned_expenses' => 'Dépenses prévues pour cette étape',
|
||||||
|
'hdl_btn_add_expense' => '+ Ajouter une dépense',
|
||||||
|
'hdl_save_fav' => 'Enregistrer dans mes favoris',
|
||||||
|
'hdl_planning_title' => 'Planning de l\'étape',
|
||||||
|
'hdl_to_pay' => 'À payer',
|
||||||
|
'hdl_js_search_loading' => 'Recherche en cours...',
|
||||||
|
'hdl_js_no_result' => 'Aucun résultat trouvé.',
|
||||||
|
'hdl_js_network_error' => 'Erreur de connexion au service de carte.',
|
||||||
|
'hdl_js_confirm_del_trip' => 'Es-tu sûr de vouloir supprimer ce voyage entier ?',
|
||||||
|
'hdl_js_confirm_del_step' => 'Supprimer cette étape ?',
|
||||||
|
'hdl_js_step_label' => 'Étape',
|
||||||
|
'hdl_js_ph_expense_name' => 'Ex: Hôtel, Péage...',
|
||||||
|
'hdl_quick_edit_title' => 'Modification rapide',
|
||||||
|
'hdl_js_edit_step' => 'Modifier l\'étape',
|
||||||
|
'hdl_js_missing_dates_title'=> 'Dates manquantes',
|
||||||
|
'hdl_js_missing_dates_msg' => 'Veuillez définir une date d\'arrivée et de départ pour organiser le planning.',
|
||||||
|
'hdl_to_place' => 'À placer',
|
||||||
|
'hdl_from' => 'Du',
|
||||||
|
'hdl_to' => 'Au',
|
||||||
|
'hdl_btn_add_step' => 'Ajouter une étape',
|
||||||
|
'hdl_quick_edit_title' => 'Modification rapide',
|
||||||
|
'hdl_paid' => 'Payé',
|
||||||
|
'weather_forecast' => 'Prévisions météo',
|
||||||
|
'weather_not_available' => 'Météo indisponible',
|
||||||
|
'temp_max' => 'Temp. Max',
|
||||||
|
'weather_loading' => 'Chargement météo...',
|
||||||
|
'weather_error' => 'Météo indisponible',
|
||||||
|
'weather_sunny' => 'Ensoleillé',
|
||||||
|
'weather_cloudy' => 'Nuageux',
|
||||||
|
'weather_rainy' => 'Pluvieux',
|
||||||
|
'weather_snowy' => 'Neigeux',
|
||||||
|
'weather_historical' => 'Basé sur l\'historique (estimation)',
|
||||||
|
'err_invalid_holiday_id' => 'ID de voyage invalide.',
|
||||||
|
'hdl_default_exp_name' => 'Dépense',
|
||||||
|
'hdl_select_beneficiary' => '-- Sélectionner --',
|
||||||
|
|
||||||
|
// Anciennes clés Holidays non préfixées (conservées pour compatibilité si utilisées)
|
||||||
|
'outbound_trip' => 'Aller',
|
||||||
|
'return_trip' => 'Retour',
|
||||||
|
'period' => 'Période',
|
||||||
|
'budget_food' => 'Budget Food & Extras',
|
||||||
|
'total_cost' => 'Coût Total Estimé',
|
||||||
|
'paid' => 'Payé',
|
||||||
|
'provisioned' => 'Provisionné',
|
||||||
|
'left_to_pay' => 'Reste à payer',
|
||||||
|
'map_itinerary' => '🗺️ Itinéraire',
|
||||||
|
'place_step' => '📍 Placer une étape',
|
||||||
|
'step_details' => '📝 Détail des étapes',
|
||||||
|
'no_steps' => 'Aucune étape planifiée.',
|
||||||
|
'travel_notes' => '📝 Notes du voyage',
|
||||||
|
'to_be_defined' => 'À définir',
|
||||||
|
'funded' => 'Financé (Provision)',
|
||||||
|
'view_planning' => 'Voir le planning',
|
||||||
|
'to_pay' => 'À payer',
|
||||||
|
'from_date' => 'Du',
|
||||||
|
'to_date' => 'au',
|
||||||
|
'passing_point' => 'Point de passage (Aucune dépense)',
|
||||||
|
'search_location' => 'Rechercher un lieu géographique',
|
||||||
|
'search_placeholder' => 'Ex: Paris, Ibis Barcelone...',
|
||||||
|
'step_name' => 'Nom de l\'étape',
|
||||||
|
'arrival_optional' => 'Arrivée le (Optionnel)',
|
||||||
|
'departure_optional' => 'Départ le (Optionnel)',
|
||||||
|
'planned_expenses' => 'Dépenses prévues',
|
||||||
|
'add_expense' => '+ Ajouter une dépense',
|
||||||
|
'save_favorite' => 'Sauvegarder dans mes favoris',
|
||||||
|
'planning_title' => 'Planning',
|
||||||
|
'modal_plan_trip' => 'Planifier le voyage',
|
||||||
|
'modal_quick_edit' => 'Modification rapide',
|
||||||
|
'err_modal_missing' => 'Erreur : La modale holidayModal est introuvable.',
|
||||||
|
'placeholder_title' => 'Intitulé',
|
||||||
|
'placeholder_price' => 'Prix (€)',
|
||||||
|
'already_paid' => 'Déjà payé ?',
|
||||||
|
'remove_line' => 'Retirer cette ligne',
|
||||||
|
'confirm_delete_trip' => 'Voulez-vous vraiment supprimer définitivement ce voyage ? Cette action est irréversible.',
|
||||||
|
'step_number' => 'Étape',
|
||||||
|
'place_new_step' => '📍 Placer une nouvelle étape',
|
||||||
|
'edit_step' => '✏️ Modifier l\'étape',
|
||||||
|
'search_in_progress' => 'Recherche en cours... ⏳',
|
||||||
|
'no_result_found' => 'Aucun résultat trouvé.',
|
||||||
|
'network_error' => 'Erreur réseau.',
|
||||||
|
'placeholder_label' => 'Libellé',
|
||||||
|
'placeholder_notes_link' => '🔗 Lien de réservation ou notes (Optionnel)...',
|
||||||
|
'confirm_delete_step' => 'Supprimer cette étape et toutes les dépenses associées ?',
|
||||||
|
'planning_of' => '📅 Planning : ',
|
||||||
|
'missing_dates_title' => '⚠️ Dates manquantes',
|
||||||
|
'missing_dates_msg' => 'Veuillez modifier cette étape (✏️) et renseigner sa <strong>Date d\'arrivée et de départ</strong>.',
|
||||||
|
'to_place' => '📥 À Placer',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BUDGET (GLOBAL & CATEGORIES)
|
||||||
|
// ==========================================
|
||||||
|
'budget_page_title' => 'HouseHub - Budget',
|
||||||
|
'budget_main_header' => 'Gestion du Budget',
|
||||||
|
'budget_tab_tracking' => 'Suivi Mensuel',
|
||||||
|
'budget_tab_prev' => 'Budget 2026',
|
||||||
|
'budget_tab_savings' => 'Épargne',
|
||||||
|
'budget_tab_recap' => 'Récapitulatif',
|
||||||
|
'budget_err_file_not_found' => 'Oups ! Fichier introuvable.',
|
||||||
|
'budget_err_file_detail' => 'Vérifiez que le fichier views/%s.php existe bien.',
|
||||||
|
'budget_err_not_authorized' => 'Page non autorisée.',
|
||||||
|
'cat_income' => 'Revenus',
|
||||||
|
'cat_fmcg' => 'Courses (FMCG)',
|
||||||
|
'cat_fuel' => 'Essence',
|
||||||
|
'cat_school' => 'École / Garde',
|
||||||
|
'cat_fixed' => 'Charges Fixes',
|
||||||
|
'cat_others' => 'Autres / Imprévus',
|
||||||
|
'cat_savings' => 'Épargne',
|
||||||
|
'budget_tab_kids' => 'Enfants',
|
||||||
|
'budget_settings_title' => 'Paramètres du Budget',
|
||||||
|
'bs_tab_accounts' => '💳 Comptes',
|
||||||
|
'bs_tab_categories' => '🏷️ Catégories',
|
||||||
|
'bs_tab_rules' => '🤖 Règles d\'import',
|
||||||
|
'bs_tab_salaries' => '💼 Salaires',
|
||||||
|
'bs_empty_accounts' => 'Aucun compte configuré.',
|
||||||
|
'bs_type_checking' => 'Courant',
|
||||||
|
'bs_type_savings' => 'Épargne',
|
||||||
|
'bs_badge_default' => 'Défaut',
|
||||||
|
'bs_owner' => 'Propriétaire',
|
||||||
|
'bs_shared_account' => 'Compte Commun',
|
||||||
|
'bs_empty_categories' => 'Aucune catégorie configurée.',
|
||||||
|
'bs_type_income' => 'Revenu',
|
||||||
|
'bs_type_expense' => 'Dépense',
|
||||||
|
'bs_type_savings_cat' => 'Épargne',
|
||||||
|
'bs_empty_rules' => 'Aucune règle d\'importation.',
|
||||||
|
'bs_empty_salaries' => 'Aucun salaire défini pour l\'année',
|
||||||
|
'bs_salaries_year_desc' => 'Configurations pour l\'année',
|
||||||
|
'bs_salary_net' => 'Salaire net',
|
||||||
|
'bs_salary_fees' => 'Mensualité / Frais',
|
||||||
|
'bs_add_account_title' => '+ Ajouter un compte',
|
||||||
|
'bs_account_name_ph' => 'Nom (ex: Livret A Bébé)',
|
||||||
|
'bs_confirm_delete' => 'Voulez-vous vraiment supprimer ce compte ?',
|
||||||
|
'bs_add_category_title' => '+ Ajouter une catégorie',
|
||||||
|
'bs_cat_code_ph' => 'Code (ex: FOOD)',
|
||||||
|
'bs_cat_label_ph' => 'Libellé (ex: Courses)',
|
||||||
|
'bs_cat_icon_ph' => 'Icône (ex: 🛒)',
|
||||||
|
'bs_confirm_delete_cat' => 'Voulez-vous vraiment supprimer cette catégorie ?',
|
||||||
|
'bs_add_rule_title' => 'Ajouter une règle',
|
||||||
|
'bs_rule_keyword_ph' => 'Mot-clé (ex: AUCHAN)',
|
||||||
|
'bs_confirm_delete_rule' => 'Voulez-vous vraiment supprimer cette règle ?',
|
||||||
|
'bs_currency_label' => 'Devise du Foyer',
|
||||||
|
'bs_currency_desc' => 'Devise principale utilisée pour l\'affichage de vos comptes et budgets.',
|
||||||
|
'bs_currency_updated' => 'Devise mise à jour avec succès !',
|
||||||
|
// ONGLET CSV MAPPING
|
||||||
|
'bs_csv_title' => 'Format de l\'import CSV',
|
||||||
|
'bs_csv_desc' => 'Configurez ici les colonnes correspondantes à l\'export de votre banque.',
|
||||||
|
'bs_csv_delimiter' => 'Séparateur',
|
||||||
|
'bs_csv_delim_semi' => 'Point-virgule (;)',
|
||||||
|
'bs_csv_delim_comma' => 'Virgule (,)',
|
||||||
|
'bs_csv_delim_tab' => 'Tabulation',
|
||||||
|
'bs_csv_date_format' => 'Format de la Date',
|
||||||
|
'bs_csv_col_index' => 'Index des colonnes',
|
||||||
|
'bs_csv_col_index_help' => '(Attention : la 1ère colonne est la Numéro 0)',
|
||||||
|
'bs_csv_col_date' => 'N° Col : Date',
|
||||||
|
'bs_csv_col_label' => 'N° Col : Libellé / Motif',
|
||||||
|
'bs_csv_amount_mgmt' => 'Gestion des montants',
|
||||||
|
'bs_csv_amount_single' => 'Une seule colonne (Positif = Revenu, Négatif = Dépense)',
|
||||||
|
'bs_csv_amount_split' => 'Deux colonnes séparées (Débit / Crédit)',
|
||||||
|
'bs_csv_col_amount' => 'N° Col : Montant',
|
||||||
|
'bs_csv_col_debit' => 'N° Col : Débit',
|
||||||
|
'bs_csv_col_credit' => 'N° Col : Crédit',
|
||||||
|
'bs_csv_col_ref' => 'N° Col : Référence unique (Optionnel)',
|
||||||
|
'btn_save_format' => 'Enregistrer le format',
|
||||||
|
'bs_csv_saved' => 'Format CSV enregistré',
|
||||||
|
'bs_tab_csv' => '📥 Import CSV',
|
||||||
|
'bs_csv_settings_title' => 'Configuration du format',
|
||||||
|
'bs_csv_drop_title' => 'Glissez votre fichier CSV ici',
|
||||||
|
'bs_csv_drop_desc' => 'ou cliquez pour parcourir vos fichiers',
|
||||||
|
'bs_csv_err_type' => 'Erreur : Seuls les fichiers .csv sont autorisés.',
|
||||||
|
'bs_csv_file_ok' => 'Fichier chargé',
|
||||||
|
'bs_csv_preview_title' => 'Aperçu de vos colonnes',
|
||||||
|
'bs_csv_empty_col' => 'Vide',
|
||||||
|
'bs_csv_no_data' => 'Aucune ligne de transaction trouvée dans ce fichier.',
|
||||||
|
|
||||||
|
// --- BUDGET : SUIVI ---
|
||||||
|
'bud_rem_school' => 'École (Reste estimé)',
|
||||||
|
'bud_rem_fmcg' => 'Courses (Reste estimé)',
|
||||||
|
'bud_rem_fuel' => 'Essence (Reste estimé)',
|
||||||
|
'bud_closed_archived' => 'Ce mois est clôturé et archivé.',
|
||||||
|
'bud_budget_of' => 'Budget :',
|
||||||
|
'bud_confirm_close' => 'Clôturer ce mois ? Cela figera les soldes actuels et basculera le suivi par défaut sur le mois prochain.',
|
||||||
|
'bud_close_btn' => 'Clôturer',
|
||||||
|
'bud_confirm_reopen' => 'Rouvrir ce mois ? Ses soldes seront recalculés en direct.',
|
||||||
|
'bud_reopen_btn' => 'Rouvrir',
|
||||||
|
'bud_active_month_btn' => 'Mois Actif',
|
||||||
|
'bud_bank_balance' => 'Solde bancaire',
|
||||||
|
'bud_frozen' => 'Figé',
|
||||||
|
'bud_current' => 'Actuel',
|
||||||
|
'bud_update_btn' => 'MàJ',
|
||||||
|
'bud_theoretical_balance' => 'Solde théorique fin de mois',
|
||||||
|
'bud_upcoming_charges' => 'Charges à venir',
|
||||||
|
'bud_all_paid' => 'Tout est payé !',
|
||||||
|
'bud_capacity_tooltip' => 'Calcul : Report Mois Précédent (%s€) + Salaires (%s€) + Autres rentrées (%s€)',
|
||||||
|
'bud_max_capacity' => 'Capacité Max',
|
||||||
|
'bud_actual' => 'Actuel',
|
||||||
|
'bud_theoretical' => 'Théorique',
|
||||||
|
'bud_import_csv' => 'Importer CSV',
|
||||||
|
'bud_csv_file' => 'Fichier CSV (Point-virgule)',
|
||||||
|
'bud_preview' => 'Prévisualiser',
|
||||||
|
'bud_validate_import' => 'Valider l\'importation',
|
||||||
|
'bud_assign_month' => 'Imputer au mois',
|
||||||
|
'bud_op_date' => 'Date Opération',
|
||||||
|
'bud_label' => 'Libellé',
|
||||||
|
'bud_amount' => 'Montant',
|
||||||
|
'bud_category' => 'Catégorie',
|
||||||
|
'bud_ignore' => 'Ignorer',
|
||||||
|
'bud_to_define' => 'À définir',
|
||||||
|
'bud_is_charge' => 'Charge ?',
|
||||||
|
'bud_is_income' => 'Revenu ?',
|
||||||
|
'btn_import' => 'Importer',
|
||||||
|
'bud_no_lines' => 'Aucune ligne.',
|
||||||
|
'bud_confirm_delete' => 'Supprimer cette ligne ?',
|
||||||
|
'bud_new_transaction' => 'Nouvelle transaction',
|
||||||
|
'bud_assign_to_month' => 'Imputer au mois de gestion',
|
||||||
|
'bud_real_date' => 'Date réelle de l\'opération',
|
||||||
|
'bud_type' => 'Type',
|
||||||
|
'bud_type_expense' => 'Dépense',
|
||||||
|
'bud_type_income' => 'Revenu / Remboursement',
|
||||||
|
'bud_beneficiary' => 'Bénéficiaire',
|
||||||
|
'bud_fixed_charge' => 'Charge Fixe',
|
||||||
|
'bud_expected_income' => 'Revenu Attendu',
|
||||||
|
'bud_amount_eur' => 'Montant (€)',
|
||||||
|
'bud_update_balance' => 'MàJ Solde Bancaire',
|
||||||
|
'bud_balance_eur' => 'Solde (€)',
|
||||||
|
'bud_add_title' => 'Ajouter',
|
||||||
|
'bud_edit_title' => 'Modifier la transaction',
|
||||||
|
'bud_to_define_js' => 'à définir',
|
||||||
|
'bud_bar_actual' => "Actuel",
|
||||||
|
'bud_bar_theoretical' => "Théorique",
|
||||||
|
'bud_bar_max_cap' => "Capacité Max",
|
||||||
|
'bud_import_csv' => "Importer CSV",
|
||||||
|
'bud_csv_file' => "Sélectionnez le fichier de votre banque (.csv)",
|
||||||
|
'bud_preview' => "Prévisualiser",
|
||||||
|
'bud_validate_import' => "Veuillez vérifier et catégoriser les lignes avant validation.",
|
||||||
|
'bud_assign_month' => "Mois de gestion",
|
||||||
|
'bud_op_date' => "Date",
|
||||||
|
'bud_amount' => "Montant",
|
||||||
|
'bud_ignore' => "Ignorer (Crédit)",
|
||||||
|
'bud_to_define' => "À définir",
|
||||||
|
'bud_is_charge' => "Est une charge fixe...",
|
||||||
|
'bud_is_income' => "Est un revenu...",
|
||||||
|
'bud_edit_title' => 'Modifier la transaction',
|
||||||
|
'bud_to_define_js' => 'à définir',
|
||||||
|
'bud_select_beneficiary' => 'Choisir un revenu attendu',
|
||||||
|
'bud_bar_actual' => 'Actuel',
|
||||||
|
|
||||||
|
// ONBOARDING BUDGET
|
||||||
|
'bs_onb_title' => 'Bienvenue ! Configurons votre budget :',
|
||||||
|
'bs_onb_desc' => 'Complétez ces étapes pour que l\'application puisse calculer votre Reste à Vivre :',
|
||||||
|
'bs_onb_cat_link' => 'Catégories',
|
||||||
|
'bs_onb_cat_desc' => 'Créez au moins une catégorie de dépenses.',
|
||||||
|
'bs_onb_acc_link' => 'Comptes Bancaires',
|
||||||
|
'bs_onb_acc_desc' => 'Ajoutez au moins un compte courant ou épargne.',
|
||||||
|
'bs_onb_sal_link' => 'Salaires',
|
||||||
|
'bs_onb_sal_desc' => 'Renseignez les rentrées d\'argent de l\'année en cours.',
|
||||||
|
'bs_onb_items_link' => 'Lignes de Budget',
|
||||||
|
'bs_onb_items_desc' => 'Rendez-vous dans l\'onglet Récapitulatif pour lister vos charges fixes (Loyer, Assurances...).',
|
||||||
|
'bs_rule_search_ph' => '🔍 Rechercher un mot-clé (ex: AUCHAN)...',
|
||||||
|
'error_loading_settings' => 'Erreur lors du chargement des paramètres',
|
||||||
|
'settings' => 'Paramètres',
|
||||||
|
|
||||||
|
// --- BUDGET : RECAP ---
|
||||||
|
'bud_recap_monthly_title' => 'Récapitulatif Mensuel',
|
||||||
|
'bud_recap_btn_add' => 'Ajouter un frais / revenu',
|
||||||
|
'bud_planned_amount' => 'Montant Prévu',
|
||||||
|
'bud_day' => 'Jour',
|
||||||
|
'bud_month_state' => 'État du mois',
|
||||||
|
'bud_est_short' => 'Est.',
|
||||||
|
'bud_reg_planned_in' => 'Régul. prévue en %s',
|
||||||
|
'bud_bonus' => 'Bonus',
|
||||||
|
'bud_overrun' => 'Dépassement',
|
||||||
|
'bud_remaining' => 'Reste',
|
||||||
|
'bud_exact_amount' => 'Montant exact',
|
||||||
|
'bud_per_month_short' => 'Soit',
|
||||||
|
'bud_month_short' => 'mois',
|
||||||
|
'bud_freq_mensuel' => 'Mensuel',
|
||||||
|
'bud_freq_annuel' => 'Annuel',
|
||||||
|
'bud_state_validated' => 'Validé',
|
||||||
|
'bud_state_partial' => 'Partiel',
|
||||||
|
'bud_state_waiting' => 'En attente',
|
||||||
|
'bud_total_income_smoothed' => 'Total Revenus (Lissés)',
|
||||||
|
'bud_total_expenses_smoothed'=> 'Total Dépenses (Lissées)',
|
||||||
|
'bud_theoretical_balance_recap'=> 'Équilibre théorique',
|
||||||
|
'bud_recap_footer_note' => 'L\'état de paiement se met à jour automatiquement en fonction des opérations importées dans l\'onglet "Suivi Mensuel".',
|
||||||
|
'bud_recap_modal_add' => 'Ajouter un élément',
|
||||||
|
'bud_recap_modal_edit' => 'Modifier',
|
||||||
|
'bud_label_name' => 'Nom',
|
||||||
|
'bud_ph_item_name' => 'Ex: Loyer, Salaire...',
|
||||||
|
'bud_label_keywords' => 'Mots-clés (Détection Auto)',
|
||||||
|
'bud_ph_keywords' => 'Ex: NETFLIX, PRIME VIDEO (séparés par virgule)',
|
||||||
|
'bud_help_keywords' => 'Si une dépense du mois contient ce mot, la ligne passera en "Validé".',
|
||||||
|
'bud_label_day' => 'Jour (1-31)',
|
||||||
|
'bud_cat_income' => 'Épargne',
|
||||||
|
'bud_cat_expense' => 'Dépense (Frais)',
|
||||||
|
'bud_label_frequency' => 'Fréquence',
|
||||||
|
'bud_label_amount_type' => 'Type de montant',
|
||||||
|
'bud_type_fixed' => 'Fixe (Facture)',
|
||||||
|
'bud_type_variable' => 'Variable (Estimation)',
|
||||||
|
'bud_label_regularization' => 'Régularisation',
|
||||||
|
'bud_reg_none' => 'Aucune',
|
||||||
|
'bud_recap_confirm_delete' => 'Voulez-vous vraiment supprimer cet élément ?',
|
||||||
|
'bud_err_delete' => 'Erreur lors de la suppression.',
|
||||||
|
'bud_prev_monthly_target' => 'Montant cible / Mensualité (€)',
|
||||||
|
'bud_prev_transfer_dest' => 'Destination du virement',
|
||||||
|
'bud_prev_none' => 'Aucune destination (Charge standard)',
|
||||||
|
|
||||||
|
|
||||||
|
// --- BUDGET : EPARGNE ---
|
||||||
|
'bud_sav_add_month' => 'Saisir un mois',
|
||||||
|
'bud_sav_add_one_month' => '+1 Mois',
|
||||||
|
'bud_sav_no_data' => 'Aucune donnée pour %s.',
|
||||||
|
'bud_sav_post_month' => 'Poste / Mois',
|
||||||
|
'bud_sav_from_date' => 'Dès le %s',
|
||||||
|
'bud_sav_edit_modal' => 'Modifier avec Modale',
|
||||||
|
'bud_sav_delete_month' => 'Supprimer tout le mois',
|
||||||
|
'bud_sav_total_bank' => 'Total Banque',
|
||||||
|
'bud_sav_extra' => 'Extra',
|
||||||
|
'bud_sav_modal_title_add' => 'Saisir un mois',
|
||||||
|
'bud_sav_modal_title_edit' => 'Modifier :',
|
||||||
|
'bud_sav_month_concerned' => 'Mois concerné',
|
||||||
|
'bud_sav_total_bank_eur' => 'Total en Banque (€)',
|
||||||
|
'bud_sav_ventilation' => 'Ventilation',
|
||||||
|
'bud_sav_adj_help' => 'Utilisez l\'ajustement (+/-) pour recalculer automatiquement.',
|
||||||
|
'bud_sav_add_line' => 'Ligne',
|
||||||
|
'bud_sav_current' => 'Actuel',
|
||||||
|
'bud_sav_adjust' => 'Ajust (+/-)',
|
||||||
|
'bud_sav_new' => 'Nouveau',
|
||||||
|
'bud_sav_sum_mode_title' => 'Activer l\'addition rapide',
|
||||||
|
'bud_sav_selection' => 'Sélection :',
|
||||||
|
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
|
||||||
|
'bud_sav_saving' => 'Enregistrement...',
|
||||||
|
'bud_err_tech' => 'Une erreur technique est survenue.',
|
||||||
|
'bud_sav_confirm_delete_month' => 'Supprimer TOUT le mois de %m pour %o ?',
|
||||||
|
'bud_sav_prompt_duplicate' => "Voulez-vous copier les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
|
||||||
|
'bud_err_server' => 'Erreur serveur : ',
|
||||||
|
'bud_err_network_dup' => 'Erreur réseau lors de la duplication.',
|
||||||
|
'budget_tab_kids' => 'Enfants 👶',
|
||||||
|
'bud_sav_add_one_month' => '+1 Mois',
|
||||||
|
'bud_sav_add_month' => 'Ajouter un mois',
|
||||||
|
'bud_sav_no_data' => 'Aucune donnée d\'épargne pour %s.',
|
||||||
|
'bud_sav_post_month' => 'Poste / Mois',
|
||||||
|
'bud_sav_from_date' => 'Dès le %s',
|
||||||
|
'bud_sav_edit_modal' => 'Éditer ce mois',
|
||||||
|
'bud_sav_delete_month' => 'Supprimer ce mois',
|
||||||
|
'bud_sav_total_bank' => 'TOTAL BANQUE',
|
||||||
|
'bud_sav_extra' => 'NON ALLOUÉ (EXTRAS)',
|
||||||
|
'bud_sav_modal_title_add' => 'Ajouter un mois',
|
||||||
|
'bud_sav_modal_title_edit' => 'Éditer',
|
||||||
|
'bud_sav_month_concerned' => 'Mois concerné',
|
||||||
|
'bud_sav_total_bank_eur' => 'Total en Banque (€)',
|
||||||
|
'bud_sav_ventilation' => 'Ventilation des comptes',
|
||||||
|
'bud_sav_adj_help' => 'Saisissez les ajustements (+/-) pour recalculer automatiquement.',
|
||||||
|
'bud_sav_add_line' => 'Ajouter une ligne',
|
||||||
|
'bud_category' => 'Catégorie',
|
||||||
|
'bud_sav_current' => 'Actuel',
|
||||||
|
'bud_sav_adjust' => 'Ajustement',
|
||||||
|
'bud_sav_new' => 'Nouveau',
|
||||||
|
'bud_sav_sum_mode_title' => 'Activer/Désactiver la calculatrice',
|
||||||
|
'bud_sav_selection' => 'Sélection :',
|
||||||
|
'bud_sav_ph_name' => 'Nom du compte',
|
||||||
|
|
||||||
|
|
||||||
|
// --- BUDGET : PREVISION ---
|
||||||
|
|
||||||
|
'budget_provisions_title' => 'Provisions & Optimisation de Trésorerie',
|
||||||
|
'btn_optimize_cashflow' => 'Optimiser mes Économies',
|
||||||
|
'add_new_provision' => 'Anticiper une grosse dépense à venir',
|
||||||
|
'provision_label' => 'Libellé de la dépense',
|
||||||
|
'provision_placeholder_wood' => 'Ex: Commande bois de chauffage, Révision voiture...',
|
||||||
|
'amount' => 'Montant',
|
||||||
|
'expected_date' => 'Date prévue',
|
||||||
|
'btn_add' => 'Ajouter',
|
||||||
|
'upcoming_provisions_list' => 'Dépenses prévues au calendrier',
|
||||||
|
'optimization_assistant_title' => 'Assistant d\'Optimisation des Quinzaines',
|
||||||
|
'error_generic' => 'Une erreur est survenue',
|
||||||
|
'success_add_provision' => 'Dépense prévisionnelle ajoutée !',
|
||||||
|
'no_provisions' => 'Aucune dépense prévisionnelle enregistrée pour le moment.',
|
||||||
|
'btn_delete' => 'Supprimer',
|
||||||
|
'savings_this_month' => 'Économies globales à placer ce mois-ci',
|
||||||
|
'calculate' => 'Calculer la stratégie optimale',
|
||||||
|
'date_past_warning' => 'La date sélectionnée est déjà passée !',
|
||||||
|
'budget_tab_provisions' => 'Provisions',
|
||||||
|
'budget_opti_base' => 'Base',
|
||||||
|
'budget_opti_debt' => 'Dette compensée',
|
||||||
|
'budget_opti_no_config' => 'Aucune configuration de salaire trouvée pour cette année.',
|
||||||
|
'budget_opti_recap_apports' => 'Résumé des apports (Clearing) :',
|
||||||
|
'budget_opti_total_disp' => 'Total disponible pour l\'optimisation :',
|
||||||
|
'budget_opti_must_pay' => 'doit verser',
|
||||||
|
|
||||||
|
|
||||||
|
// JS & Erreurs
|
||||||
|
'bud_sav_confirm_delete_month' => 'Voulez-vous vraiment supprimer toutes les données de %m pour %o ?',
|
||||||
|
'bud_sav_prompt_duplicate' => "Voulez-vous dupliquer les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
|
||||||
|
'bud_err_tech' => 'Une erreur technique est survenue.',
|
||||||
|
'bud_err_server' => 'Erreur serveur : ',
|
||||||
|
'bud_err_network_dup' => 'Erreur réseau lors de la duplication.',
|
||||||
|
'bud_sav_saving' => 'Sauvegarde en cours...',
|
||||||
|
'bud_err_delete' => 'Erreur lors de la suppression.',
|
||||||
|
|
||||||
|
// --- BUDGET : PREVISIONNEL ---
|
||||||
|
'bud_prev_incomes' => 'Revenus',
|
||||||
|
'bud_prev_person' => 'Personne',
|
||||||
|
'bud_prev_salary' => 'Salaire',
|
||||||
|
'bud_prev_monthly_payment' => 'Mensualité',
|
||||||
|
'bud_prev_joint_account' => 'Cpt Commun',
|
||||||
|
'bud_prev_func_expenses' => 'Frais Func.',
|
||||||
|
'bud_prev_perso_savings' => 'Eco Perso',
|
||||||
|
'bud_prev_family_savings' => 'Eco Family',
|
||||||
|
'bud_prev_available' => 'Dispo',
|
||||||
|
'bud_prev_perso_remaining' => 'Restant Perso',
|
||||||
|
'bud_prev_budget_alloc' => 'Répartition Budgétaire',
|
||||||
|
'bud_prev_today' => 'Auj.',
|
||||||
|
'bud_prev_new_line' => 'Nouvelle Ligne',
|
||||||
|
'bud_prev_remaining' => 'Restant',
|
||||||
|
'bud_prev_confirm_del_line' => 'Supprimer cette ligne et tout son historique ?',
|
||||||
|
'bud_prev_notes_for' => 'Notes pour',
|
||||||
|
'bud_prev_saved' => 'Enregistré',
|
||||||
|
'bud_prev_notes_ph' => 'Écrivez vos remarques, rappels ou commentaires pour ce mois ici...',
|
||||||
|
'bud_prev_save_note' => 'Enregistrer la note',
|
||||||
|
'bud_prev_transfers_to_make' => 'Virements à effectuer',
|
||||||
|
'bud_prev_destination' => 'Destination',
|
||||||
|
'bud_prev_done' => 'FAIT',
|
||||||
|
'bud_prev_validate' => 'Valider',
|
||||||
|
'bud_prev_grand_total' => 'GRAND TOTAL',
|
||||||
|
'bud_prev_new_line_title' => 'Nouvelle Ligne de Budget',
|
||||||
|
'bud_prev_edit_line_title' => 'Modifier la ligne',
|
||||||
|
'bud_prev_label_target' => 'Cible (Destination)',
|
||||||
|
'bud_prev_choose' => 'Choisir',
|
||||||
|
'bud_prev_link_holiday' => 'Lier à un voyage (Optionnel)',
|
||||||
|
'bud_prev_no_link' => 'Ne pas associer',
|
||||||
|
'bud_prev_err_no_history' => 'Impossible : pas d\'historique disponible pour copier.',
|
||||||
|
'bud_prev_confirm_copy' => "Voulez-vous copier les données de %s vers %t ?\n\n⚠️ Cela écrasera toutes les valeurs déjà présentes pour %t.",
|
||||||
|
'bud_prev_confirm_transfers' => "Confirmer que %p a bien effectué tous ses virements pour %m ?\n\nCela mettra à jour l'Épargne automatiquement.",
|
||||||
|
'bud_sav_modal_title_add' => 'Saisir un mois',
|
||||||
|
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
|
||||||
|
'bud_prev_label_name' => 'Nom',
|
||||||
|
'bud_adv_title' => 'Avances & Tricount Familial',
|
||||||
|
'bud_adv_add' => 'Ajouter une avance',
|
||||||
|
'bud_adv_edit' => 'Modifier l\'avance',
|
||||||
|
'bud_adv_who_paid' => 'Qui a payé ?',
|
||||||
|
'bud_adv_payer' => 'Payeur',
|
||||||
|
'bud_adv_has_advanced' => '%s a avancé :',
|
||||||
|
'bud_adv_cc_label' => 'sur le compte commun',
|
||||||
|
'bud_adv_livret_label' => 'depuis son livret d\'épargne',
|
||||||
|
'bud_adv_cc_balance_title' => 'Équilibre du Compte Commun',
|
||||||
|
'bud_adv_owed_to' => '<strong>%s €</strong> sont dus à <strong>%s</strong>',
|
||||||
|
'bud_adv_balanced' => 'Les comptes sont parfaitement équilibrés !',
|
||||||
|
'bud_adv_saved_badge' => 'Épargne',
|
||||||
|
'bud_adv_already_saved' => 'Cet argent provient d\'un livret d\'épargne',
|
||||||
|
'bud_adv_ph_desc' => 'Ex: Courses, Facture Électricité...',
|
||||||
|
'bud_adv_confirm_resolve' => 'Confirmer le remboursement complet de cette avance ?',
|
||||||
|
'bud_adv_confirm_delete' => 'Supprimer définitivement cette avance ?',
|
||||||
|
'bud_prev_target_ph' => 'Ex: 150',
|
||||||
|
'bud_prev_transfer_to' => 'Vers',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MODULE : CADEAUX (gift-list)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// 1. Titres et en-têtes
|
||||||
|
'gift_page_title' => 'Liste des Cadeaux',
|
||||||
|
'gift_main_title' => 'Cadeaux de %s', // %s sera remplacé par l'année (ex: Cadeaux de 2026)
|
||||||
|
'settings' => 'Paramètres',
|
||||||
|
|
||||||
|
// 2. Filtres
|
||||||
|
'gift_filter_all_children' => 'Tous les enfants',
|
||||||
|
'gift_filter_all_adults' => 'Tous les adultes',
|
||||||
|
|
||||||
|
// 3. Contenu et états vides
|
||||||
|
'gift_add_gift' => 'Ajouter',
|
||||||
|
'gift_empty_state_no_gifts' => 'Aucun cadeau enregistré pour cette fête.',
|
||||||
|
'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond à vos filtres.',
|
||||||
|
'gift_paid_by' => 'Payé par %s', // %s sera remplacé par le nom du payeur
|
||||||
|
|
||||||
|
// 4. Section Tricount (Bilan & Remboursements)
|
||||||
|
'gift_liquidations' => 'Bilan & Remboursements',
|
||||||
|
'gift_no_debt' => 'Tout est à jour, aucune dette !',
|
||||||
|
'gift_owes' => 'doit',
|
||||||
|
'gift_view_matrix' => 'Voir la matrice détaillée',
|
||||||
|
'gift_debtor' => 'Débiteur',
|
||||||
|
'gift_creditor' => 'Créancier',
|
||||||
|
|
||||||
|
// 5. Modale : Ajout/Modification de Cadeau
|
||||||
|
'gift_col_adult' => 'Pour le compte de',
|
||||||
|
'gift_modal_payer' => 'Payé par',
|
||||||
|
'gift_modal_gift_name' => 'Description du cadeau',
|
||||||
|
'gift_modal_price' => 'Prix estimé / payé (€)',
|
||||||
|
'gift_modal_link' => 'Lien du produit (URL)',
|
||||||
|
'gift_modal_title_add' => 'Ajouter pour %s', // JS : %s = nom de l'enfant
|
||||||
|
'gift_modal_title_edit' => 'Modifier le cadeau', // JS
|
||||||
|
'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?', // JS
|
||||||
|
|
||||||
|
// 6. Modale : Paramètres des Fêtes (Nouveau !)
|
||||||
|
'gift_settings_title' => 'Configuration des Fêtes',
|
||||||
|
'gift_settings_add_title' => '+ Ajouter une occasion',
|
||||||
|
'gift_settings_name_placeholder' => 'Nom (ex: Saint Valentin)',
|
||||||
|
'gift_settings_date_placeholder' => 'MM-JJ',
|
||||||
|
'gift_settings_date_help' => 'La date (MM-JJ) est optionnelle. Laissez vide pour une fête mobile.',
|
||||||
|
'gift_settings_empty' => 'Aucune fête configurée.', // JS
|
||||||
|
'gift_settings_active' => 'Actif', // JS
|
||||||
|
|
||||||
|
// 7. Boutons génériques (si tu ne les as pas déjà en global)
|
||||||
|
'btn_cancel' => 'Annuler',
|
||||||
|
'btn_save' => 'Enregistrer',
|
||||||
|
'btn_add' => 'Ajouter',
|
||||||
|
'loading' => 'Chargement...',
|
||||||
|
|
||||||
|
// --- MODULE CADEAUX : SETTINGS ---
|
||||||
|
'gift_settings' => 'Configuration',
|
||||||
|
'gift_tab_occasions' => 'Fêtes & Occasions',
|
||||||
|
'gift_tab_proches' => 'Proches (Hors foyer)',
|
||||||
|
'gift_settings_icon' => 'Icône',
|
||||||
|
'gift_settings_date' => 'Date (MM-JJ)',
|
||||||
|
'gift_settings_add_occ' => 'Ajouter une fête',
|
||||||
|
'gift_settings_add_proche'=> 'Ajouter un proche',
|
||||||
|
'gift_role_proche_adulte'=> 'Adulte (Oncle, ami...)',
|
||||||
|
'gift_role_proche_enfant'=> 'Enfant (Neveu, nièce...)',
|
||||||
|
'gift_ph_proche_name' => 'Prénom',
|
||||||
|
'gift_role_label' => 'Rôle',
|
||||||
|
'gift_settings_saved' => 'Paramètres enregistrés !',
|
||||||
|
'gift_settings_edit_occ' => 'Modifier la fête',
|
||||||
|
'gift_settings_edit_proche' => 'Modifier le proche',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// GARAGE MANAGER
|
||||||
|
// ==========================================
|
||||||
|
'menu_garage' => 'Garage',
|
||||||
|
'garage_page_title' => 'HouseHub - Garage',
|
||||||
|
'garage_stat_vehicles' => 'Véhicules',
|
||||||
|
'garage_stat_maintenances' => 'Entretiens',
|
||||||
|
'garage_stat_parts' => 'Pièces',
|
||||||
|
'garage_stat_cost' => 'Coût total',
|
||||||
|
'garage_my_vehicles' => 'Mes véhicules',
|
||||||
|
'garage_reminders' => 'Rappels & prochains entretiens',
|
||||||
|
'garage_vehicles_title' => 'Mes véhicules',
|
||||||
|
'garage_all_parts' => 'Toutes les pièces',
|
||||||
|
'garage_parts_count' => 'pièces',
|
||||||
|
'garage_maintenances' => 'Entretiens',
|
||||||
|
'garage_parts' => 'Pièces',
|
||||||
|
'garage_add' => 'Ajouter',
|
||||||
|
'garage_add_vehicle' => 'Ajouter un véhicule',
|
||||||
|
'garage_add_maintenance' => 'Ajouter un entretien',
|
||||||
|
'garage_add_part' => 'Ajouter une pièce',
|
||||||
|
'garage_vehicle_name' => 'Nom du véhicule',
|
||||||
|
'garage_brand' => 'Marque',
|
||||||
|
'garage_model' => 'Modèle',
|
||||||
|
'garage_year' => 'Année',
|
||||||
|
'garage_plate' => 'Immatriculation',
|
||||||
|
'garage_fuel' => 'Carburant',
|
||||||
|
'garage_km' => 'Kilométrage actuel',
|
||||||
|
'garage_color' => 'Couleur',
|
||||||
|
'garage_purchase_date' => 'Date d\'achat',
|
||||||
|
'garage_purchase_price' => 'Prix d\'achat (€)',
|
||||||
|
'garage_photo' => 'Photo',
|
||||||
|
'garage_notes' => 'Notes',
|
||||||
|
'garage_maint_type' => 'Type d\'entretien',
|
||||||
|
'garage_date' => 'Date',
|
||||||
|
'garage_km_at' => 'Kilométrage au moment',
|
||||||
|
'garage_labor_cost' => 'Coût main d\'œuvre (€)',
|
||||||
|
'garage_description' => 'Description',
|
||||||
|
'garage_mechanic' => 'Mécanicien',
|
||||||
|
'garage_garage' => 'Garage / Atelier',
|
||||||
|
'garage_next_reminder' => 'Prochain entretien (rappel)',
|
||||||
|
'garage_next_date' => 'Date prévue',
|
||||||
|
'garage_next_km' => 'Kilométrage prévu',
|
||||||
|
'garage_part_name' => 'Nom de la pièce',
|
||||||
|
'garage_category' => 'Catégorie',
|
||||||
|
'garage_reference' => 'Référence',
|
||||||
|
'garage_unit_price' => 'Prix unitaire (€)',
|
||||||
|
'garage_quantity' => 'Quantité',
|
||||||
|
'garage_unit' => 'Unité',
|
||||||
|
'garage_supplier' => 'Fournisseur',
|
||||||
|
'garage_change_photo' => 'Changer la photo',
|
||||||
|
'garage_new_photo' => 'Nouvelle photo',
|
||||||
|
'garage_update_photo' => 'Mettre à jour',
|
||||||
|
];
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
// includes/meta_db.php
|
||||||
|
// Connexion à la base meta (gestion des familles et utilisateurs)
|
||||||
|
|
||||||
|
$envHost = getenv('DB_HOST');
|
||||||
|
|
||||||
|
// Détection stricte : Docker injecte toujours 'househub-db' via le docker-compose
|
||||||
|
if ($envHost === 'househub-db') {
|
||||||
|
// Environnement Docker (Ton collègue ou la Prod)
|
||||||
|
$meta_host = 'househub-db';
|
||||||
|
$meta_user = getenv('DB_USER') ?: 'househub';
|
||||||
|
$meta_pass = getenv('DB_PASS') ?: 'changeme';
|
||||||
|
} else {
|
||||||
|
// Environnement Local XAMPP (Toi)
|
||||||
|
$meta_host = '127.0.0.1'; // Plus stable que 'localhost' sous XAMPP
|
||||||
|
$meta_user = 'root';
|
||||||
|
$meta_pass = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta_db = 'househub_meta';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$meta_pdo = new PDO(
|
||||||
|
"mysql:host=$meta_host;dbname=$meta_db;charset=utf8mb4",
|
||||||
|
$meta_user,
|
||||||
|
$meta_pass,
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
die(json_encode(['error' => 'Meta DB unavailable: ' . $e->getMessage()]));
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
$pageTitle = "PachaFamily - Accueil";
|
|
||||||
$activePage = "home";
|
|
||||||
require __DIR__ . '/header.php';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<h1>Bienvenue sur PachaFamily</h1>
|
|
||||||
<p>Centre de controle de l'organisation familiale.</p>
|
|
||||||
|
|
||||||
<section class="pf-section">
|
|
||||||
<h2>Modules</h2>
|
|
||||||
<ul>
|
|
||||||
<li><a href="/family-calendar.php">Family calendar (conges, vacances, garde)</a></li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
require __DIR__ . '/footer.php';
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
// Protection de la page : nécessite d'être connecté
|
||||||
|
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login('/login.php');
|
||||||
|
|
||||||
|
// Inclusion i18n (déjà fait dans header, mais par sécurité si besoin de tr() avant)
|
||||||
|
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
// Configuration de la page
|
||||||
|
$pageTitle = tr('home_title');
|
||||||
|
$activePage = "home";
|
||||||
|
// background géré par home.css via body[data-page="home"]
|
||||||
|
$pageCss = "/modules/home/home.css";
|
||||||
|
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
|
||||||
|
// Override background si image personnalisée uploadée
|
||||||
|
$_fid = $_SESSION['user']['family_id'] ?? null;
|
||||||
|
$_has_custom_bg = false;
|
||||||
|
if ($_fid) {
|
||||||
|
foreach (glob('/uploads/home_bg_' . $_fid . '.*') as $_f) { $_has_custom_bg = true; break; }
|
||||||
|
}
|
||||||
|
if ($_has_custom_bg): ?>
|
||||||
|
<style>body[data-page="home"] { background-image: url('/home-bg.php?v=<?= filemtime($_f) ?>'); }</style>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="pf-container">
|
||||||
|
|
||||||
|
<div class="pf-hero">
|
||||||
|
<h1><?= tr('home_welcome') ?></h1>
|
||||||
|
<p><?= tr('home_subtitle') ?></p>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['user'])): ?>
|
||||||
|
<div class="pf-user-info" style="margin-top: 12px; font-weight: 500;">
|
||||||
|
<?= tr('home_logged_as') ?>
|
||||||
|
<strong><?= htmlspecialchars($_SESSION['user']['display_name'] ?? $_SESSION['user']['username']) ?></strong>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="pf-section">
|
||||||
|
<h2 style="color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.6);"><?= tr('home_modules_title') ?></h2>
|
||||||
|
|
||||||
|
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','garage','calendar_ios']; ?>
|
||||||
|
<div class="pf-modules-grid">
|
||||||
|
|
||||||
|
<?php if (in_array('calendar', $mods)): ?>
|
||||||
|
<a href="/family-calendar.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">📅</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_calendar_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_calendar_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_open') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('budget', $mods)): ?>
|
||||||
|
<a href="/budget.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">💰</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_budget_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_budget_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_manage') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('holidays', $mods)): ?>
|
||||||
|
<a href="/holidays.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">🏖️</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_holidays_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_holidays_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_explore') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('gifts', $mods)): ?>
|
||||||
|
<a href="/gift-list.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">🎁</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_gifts_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_gifts_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_view_lists') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('garage', $mods)): ?>
|
||||||
|
<a href="/garage.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">🚗</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_garage_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_garage_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_service') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('memo', $mods)): ?>
|
||||||
|
<a href="/memo.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">📝</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_memo_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_memo_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_jot') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('todo', $mods)): ?>
|
||||||
|
<a href="/todo.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">✅</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_todo_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_todo_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_check') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('liste', $mods)): ?>
|
||||||
|
<a href="/liste.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">🛒</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_liste_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_liste_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_cart') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('calendar_ios', $mods)): ?>
|
||||||
|
<a href="/calendar-ios.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">📱</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_calendar_ios_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_calendar_ios_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_sync') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('printvault', $mods)): ?>
|
||||||
|
<a href="/printvault.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">🖨️</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_printvault_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_printvault_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_print') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
require __DIR__ . '/footer.php';
|
||||||
|
?>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = tr('mod_liste_name') . ' — HouseHub';
|
||||||
|
$activePage = 'liste';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/liste/assets/liste.css">
|
||||||
|
|
||||||
|
<main class="liste-main">
|
||||||
|
|
||||||
|
<!-- Tabs des listes -->
|
||||||
|
<div class="liste-tabs-bar">
|
||||||
|
<div class="liste-tabs" id="liste-tabs"></div>
|
||||||
|
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Corps de la liste active -->
|
||||||
|
<div class="liste-body" id="liste-body">
|
||||||
|
|
||||||
|
<div class="liste-add-card">
|
||||||
|
<input type="text" id="liste-input" maxlength="500"
|
||||||
|
placeholder="<?= htmlspecialchars(tr('liste_placeholder_add')) ?>" autocomplete="off">
|
||||||
|
<button id="btn-liste-add"><?= htmlspecialchars(tr('liste_btn_add')) ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="liste-toolbar" id="liste-toolbar">
|
||||||
|
<button class="btn-tool" id="btn-uncheck-all"><?= htmlspecialchars(tr('liste_btn_uncheck_all')) ?></button>
|
||||||
|
<button class="btn-tool btn-tool-danger" id="btn-delete-picked"><?= htmlspecialchars(tr('liste_btn_delete_picked')) ?></button>
|
||||||
|
<button class="btn-tool btn-tool-danger" id="btn-clear-all"><?= htmlspecialchars(tr('liste_btn_clear_all')) ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="liste-items-root"></div>
|
||||||
|
<div id="liste-history-root"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const LISTE_TRANSLATIONS = {
|
||||||
|
already_in: <?= json_encode(tr('liste_already_in')) ?>,
|
||||||
|
confirm_clear: <?= json_encode(tr('liste_confirm_clear')) ?>,
|
||||||
|
confirm_delete_list: <?= json_encode(tr('liste_confirm_delete_list')) ?>,
|
||||||
|
rename_list: <?= json_encode(tr('liste_rename_list')) ?>,
|
||||||
|
new_name: <?= json_encode(tr('liste_new_name')) ?>,
|
||||||
|
list_deleted: <?= json_encode(tr('liste_list_deleted')) ?>,
|
||||||
|
list_created: <?= json_encode(tr('liste_list_created')) ?>,
|
||||||
|
list_renamed: <?= json_encode(tr('liste_list_renamed')) ?>,
|
||||||
|
edit_placeholder: <?= json_encode(tr('liste_edit_placeholder')) ?>,
|
||||||
|
history_title: <?= json_encode(tr('liste_history_title')) ?>,
|
||||||
|
added: <?= json_encode(tr('liste_added')) ?>,
|
||||||
|
updated: <?= json_encode(tr('liste_updated')) ?>,
|
||||||
|
deleted: <?= json_encode(tr('liste_deleted')) ?>,
|
||||||
|
cancel: <?= json_encode(tr('cancel')) ?>,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="/modules/liste/assets/liste.js"></script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
require_once __DIR__ . '/includes/meta_db.php';
|
||||||
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
|
||||||
|
$pageTitle = tr('login_title');
|
||||||
|
$activePage = "login";
|
||||||
|
|
||||||
|
if (isset($_SESSION['user'])) {
|
||||||
|
header('Location: /index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
|
||||||
|
$error = "Session invalide (CSRF). Rechargez la page.";
|
||||||
|
} else {
|
||||||
|
$username = trim($_POST['username'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
if (!$username || !$password) {
|
||||||
|
$error = tr('error_missing_fields');
|
||||||
|
} else {
|
||||||
|
$stmt = $meta_pdo->prepare("
|
||||||
|
SELECT u.id, u.username, u.password_hash, u.display_name, u.family_id, u.is_admin, u.is_active, u.lang,
|
||||||
|
f.db_name, f.is_active as family_active, f.enabled_modules
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN families f ON f.id = u.family_id
|
||||||
|
WHERE u.username = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($user && password_verify($password, $user['password_hash'])) {
|
||||||
|
if (!$user['is_active']) {
|
||||||
|
$error = "Compte désactivé. Contactez l'administrateur.";
|
||||||
|
} elseif ($user['family_id'] && !$user['family_active']) {
|
||||||
|
$error = "Espace familial désactivé. Contactez l'administrateur.";
|
||||||
|
} else {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['user'] = [
|
||||||
|
'id' => (int)$user['id'],
|
||||||
|
'username' => $user['username'],
|
||||||
|
'display_name' => $user['display_name'],
|
||||||
|
'family_id' => (int)$user['family_id'],
|
||||||
|
'is_admin' => (bool)$user['is_admin'],
|
||||||
|
];
|
||||||
|
$_SESSION['family_db'] = $user['db_name'];
|
||||||
|
$_SESSION['app_lang'] = $user['lang'] ?? 'fr';
|
||||||
|
$_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts","calendar_ios"]', true);
|
||||||
|
$redirectTo = $_GET['redirect'] ?? '/index.php';
|
||||||
|
header('Location: ' . $redirectTo);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$error = tr('error_invalid_credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="pf-container pf-login-wrapper">
|
||||||
|
<div class="pf-login-card">
|
||||||
|
|
||||||
|
<header class="pf-login-header">
|
||||||
|
<img src="/favicon.png" alt="HouseHub Logo" class="pf-login-icon">
|
||||||
|
<h1><?= tr('login_header') ?></h1>
|
||||||
|
<p><?= tr('login_subtitle') ?></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="pf-login-error">
|
||||||
|
<?= htmlspecialchars($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="/login.php<?= isset($_GET['redirect']) ? '?redirect=' . urlencode($_GET['redirect']) : '' ?>">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label" for="username"><?= tr('label_username') ?></label>
|
||||||
|
<input type="text" id="username" name="username" class="pf-input"
|
||||||
|
required autofocus placeholder="<?= tr('placeholder_username') ?>"
|
||||||
|
autocomplete="username" autocapitalize="none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label" for="password"><?= tr('label_password') ?></label>
|
||||||
|
<input type="password" id="password" name="password" class="pf-input"
|
||||||
|
required placeholder="••••••" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="pf-btn pf-btn-block">
|
||||||
|
<?= tr('btn_login_submit') ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="text-align:center;margin-top:20px;font-size:0.9rem;color:#64748b">
|
||||||
|
Pas encore de compte ? <a href="/register.php" style="color:var(--primary)">Créer un espace</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// On vide toutes les variables de session
|
||||||
|
$_SESSION = [];
|
||||||
|
|
||||||
|
// On détruit la session
|
||||||
|
session_destroy();
|
||||||
|
|
||||||
|
// Optionnel : supprimer le cookie de session (plus propre)
|
||||||
|
if (ini_get("session.use_cookies")) {
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
setcookie(
|
||||||
|
session_name(),
|
||||||
|
'',
|
||||||
|
time() - 42000,
|
||||||
|
$params["path"],
|
||||||
|
$params["domain"],
|
||||||
|
$params["secure"],
|
||||||
|
$params["httponly"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On renvoie vers la page de login (ou index si tu préfères)
|
||||||
|
header('Location: /login.php');
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = 'Notes — HouseHub';
|
||||||
|
$activePage = 'memo';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/memo/assets/memo.css">
|
||||||
|
<div id="memo-toasts" class="memo-toast-container"></div>
|
||||||
|
|
||||||
|
<div class="memo-layout">
|
||||||
|
|
||||||
|
<!-- ── SIDEBAR ────────────────────────────────────────────────────────────── -->
|
||||||
|
<aside class="memo-sidebar" id="memo-sidebar">
|
||||||
|
<div class="memo-sidebar-top">
|
||||||
|
<button class="btn btn-primary" onclick="openCreate()" style="width:100%;justify-content:center">+ Nouvelle note</button>
|
||||||
|
<div class="memo-search">
|
||||||
|
<span style="color:var(--text-muted);font-size:.9rem">🔍</span>
|
||||||
|
<input type="text" id="memo-search-input" placeholder="Rechercher…">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="memo-tags-list" id="memo-tags-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── MAIN ───────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="memo-main">
|
||||||
|
|
||||||
|
<!-- Page : liste ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="memo-page-list" class="memo-page active">
|
||||||
|
<div class="memo-list-header">
|
||||||
|
<div>
|
||||||
|
<div id="memo-list-title" style="font-size:1rem;font-weight:700">📝 Notes</div>
|
||||||
|
<div id="memo-list-subtitle" class="memo-list-subtitle"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openCreate()">+ Nouvelle note</button>
|
||||||
|
</div>
|
||||||
|
<div id="memo-notes-grid" class="notes-grid"></div>
|
||||||
|
<div id="memo-pagination" style="display:flex;justify-content:space-between;align-items:center;padding:0 1.5rem 1.5rem;gap:.5rem"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page : vue note ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="memo-page-view" class="memo-page">
|
||||||
|
<div class="memo-view-header">
|
||||||
|
<div class="memo-view-title" id="view-title"></div>
|
||||||
|
<div class="memo-view-meta">
|
||||||
|
<div id="view-tags" style="display:flex;gap:.35rem;flex-wrap:wrap"></div>
|
||||||
|
<span id="view-date" style="margin-left:auto"></span>
|
||||||
|
<div class="memo-view-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="loadList(currentQ,currentTag)">← Retour</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="openEdit(currentNoteId)">✏️ Modifier</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteNote(currentNoteId)">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="memo-content-area">
|
||||||
|
<div class="md-body" id="view-md"></div>
|
||||||
|
</div>
|
||||||
|
<div class="memo-attachments" id="view-attachments" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page : édition ───────────────────────────────────────────────────── -->
|
||||||
|
<div id="memo-page-edit" class="memo-page">
|
||||||
|
<div class="memo-edit-area">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.1rem;flex-wrap:wrap;gap:.5rem">
|
||||||
|
<h2 id="edit-page-title" style="font-size:1rem;font-weight:700;margin:0">Nouvelle note</h2>
|
||||||
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<button class="btn btn-secondary" onclick="currentNoteId?viewNote(currentNoteId):loadList()">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveNote()">💾 Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="memo-edit-form">
|
||||||
|
|
||||||
|
<!-- Titre -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Titre *</label>
|
||||||
|
<input type="text" id="edit-title" class="form-control" placeholder="Titre de la note…" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu + preview -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Contenu <span style="font-size:.72rem;color:var(--text-muted)">(Markdown supporté)</span></label>
|
||||||
|
<div class="edit-split">
|
||||||
|
<textarea id="edit-content" class="form-control edit-textarea" placeholder="Écrivez ici en Markdown…"></textarea>
|
||||||
|
<div class="md-preview-box md-body" id="md-live-preview" style="min-height:320px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tags</label>
|
||||||
|
<div class="tags-input-wrap" onclick="document.getElementById('edit-tags-input').focus()">
|
||||||
|
<div id="edit-tags-chips" style="display:contents"></div>
|
||||||
|
<input type="text" id="edit-tags-input" placeholder="Ajouter un tag…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.25rem">Appuyez sur Entrée ou virgule pour valider</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fichiers -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Fichiers & Images</label>
|
||||||
|
<div id="edit-drop-zone" class="attach-drop">
|
||||||
|
📎 Glissez-déposez des fichiers ou cliquez pour choisir (images, PDF…)
|
||||||
|
<input type="file" id="edit-file-input" multiple accept="image/*,.pdf,.doc,.docx,.txt,.csv,.zip" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div id="edit-attach-preview" class="attach-preview-list"></div>
|
||||||
|
<div id="edit-existing-attachments" style="margin-top:.75rem"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URLs -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Liens / URLs</label>
|
||||||
|
<div id="edit-url-rows"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- memo-main -->
|
||||||
|
</div><!-- memo-layout -->
|
||||||
|
|
||||||
|
<!-- Lightbox -->
|
||||||
|
<div id="memo-lightbox" class="lightbox">
|
||||||
|
<img src="" alt="" onclick="event.stopPropagation()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- marked.js pour le rendu Markdown -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script>
|
||||||
|
if(window.marked){
|
||||||
|
marked.setOptions({breaks:true,gfm:true});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="/modules/memo/assets/memo.js"></script>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/includes/meta_db.php';
|
||||||
|
|
||||||
|
// Récupération des identifiants de BDD depuis l'environnement (fallback par défaut)
|
||||||
|
$db_host = getenv('DB_HOST') ?: 'househub-db';
|
||||||
|
$db_user = getenv('DB_USER') ?: 'househub';
|
||||||
|
$db_pass = getenv('DB_PASS') ?: 'changeme';
|
||||||
|
|
||||||
|
echo "<h1>🛠️ HouseHub - Correction AUTO_INCREMENT Voyages</h1>";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// On récupère toutes les familles actives
|
||||||
|
$stmt = $meta_pdo->query("SELECT db_name, name FROM families");
|
||||||
|
$families = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
foreach ($families as $f) {
|
||||||
|
$dbName = $f['db_name'];
|
||||||
|
echo "<h3>Famille : {$f['name']} ($dbName)</h3><ul>";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO("mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", $db_user, $db_pass, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1. On vérifie si la clé primaire existe déjà pour éviter de faire planter le script
|
||||||
|
$hasPrimaryKey = $pdo->query("SHOW KEYS FROM pf_holidays WHERE Key_name = 'PRIMARY'")->fetch();
|
||||||
|
|
||||||
|
if (!$hasPrimaryKey) {
|
||||||
|
$pdo->exec("ALTER TABLE pf_holidays ADD PRIMARY KEY (id)");
|
||||||
|
echo "<li>✅ Clé primaire restaurée sur la colonne `id`.</li>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. On restaure la propriété AUTO_INCREMENT
|
||||||
|
$pdo->exec("ALTER TABLE pf_holidays MODIFY id INT(11) NOT NULL AUTO_INCREMENT");
|
||||||
|
echo "<li>✅ Propriété AUTO_INCREMENT restaurée.</li>";
|
||||||
|
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// On attrape l'erreur si la modif a déjà été faite
|
||||||
|
echo "<li>ℹ️ Action ignorée ou table déjà à jour : " . $e->getMessage() . "</li>";
|
||||||
|
}
|
||||||
|
echo "</ul>";
|
||||||
|
}
|
||||||
|
echo "<h2>🚀 Correction terminée ! Tu peux réessayer de créer un voyage.</h2>";
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
die("Erreur fatale de connexion à la Meta DB : " . $e->getMessage());
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
// migrate_attachments.php
|
||||||
|
|
||||||
|
// 1. Inclusion de ta connexion BDD principale (Ajuste le chemin si besoin)
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
|
||||||
|
echo "<pre>🚀 Lancement de la migration multi-bases...\n\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Récupérer toutes les bases de données qui commencent par "househub_f"
|
||||||
|
$stmt = $pdo->query("SHOW DATABASES LIKE 'househub_f%'");
|
||||||
|
$databases = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
if (empty($databases)) {
|
||||||
|
die("❌ Aucune base de données familiale trouvée (Format househub_f1, househub_f2...).\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$successCount = 0;
|
||||||
|
|
||||||
|
foreach ($databases as $dbName) {
|
||||||
|
// Sécurité : on vérifie le format exact (househub_f suivi de chiffres)
|
||||||
|
if (preg_match('/^househub_f\d+$/', $dbName)) {
|
||||||
|
echo "⚙️ Mise à jour de la base : <strong>$dbName</strong>... ";
|
||||||
|
|
||||||
|
// On bascule la connexion sur la base de la famille
|
||||||
|
$pdo->exec("USE `$dbName`");
|
||||||
|
|
||||||
|
// 3. Création de la table
|
||||||
|
$sql = "CREATE TABLE IF NOT EXISTS pf_holidays_attachments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
holiday_id INT NOT NULL,
|
||||||
|
item_id INT DEFAULT NULL,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
file_path VARCHAR(255) NOT NULL,
|
||||||
|
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_holiday_attachment FOREIGN KEY (holiday_id) REFERENCES pf_holidays(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
|
||||||
|
|
||||||
|
$pdo->exec($sql);
|
||||||
|
echo "✅ OK\n";
|
||||||
|
$successCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n🎉 Migration terminée avec succès sur $successCount base(s) familiale(s) !</pre>";
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
die("\n❌ Erreur SQL pendant la migration : " . $e->getMessage() . "</pre>");
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
// migrate_categories_order.php
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONFIGURATION DE CONNEXION (À ADAPTER)
|
||||||
|
// ============================================================================
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
|
||||||
|
|
||||||
|
echo "<h2>🚀 Migration : Ajout de l'ordre des catégories</h2>";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connexion au serveur MySQL (sans spécifier de base de données particulière)
|
||||||
|
$pdo = new PDO("mysql:host=$host;charset=utf8mb4", $user, $pass);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
// 1. Trouver toutes les bases de données de la famille HouseHub
|
||||||
|
$stmt = $pdo->query("SHOW DATABASES LIKE 'househub_f%'");
|
||||||
|
$databases = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
if (empty($databases)) {
|
||||||
|
echo "<p style='color: #ef4444;'>❌ Aucune base de données correspondant à 'househub_f*' n'a été trouvée.</p>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "<p>🔍 <strong>" . count($databases) . "</strong> base(s) de données trouvée(s).</p>";
|
||||||
|
|
||||||
|
// 2. Boucler sur chaque base de données
|
||||||
|
foreach ($databases as $db) {
|
||||||
|
echo "<hr><div style='padding: 10px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 10px;'>";
|
||||||
|
echo "<h4 style='margin-top: 0;'>📂 Traitement de la base : <strong>$db</strong></h4>";
|
||||||
|
|
||||||
|
// Sélectionner la base de données active
|
||||||
|
$pdo->exec("USE `$db`");
|
||||||
|
|
||||||
|
// Vérifier si la table pf_budget_categories existe dans cette DB
|
||||||
|
$tableExists = $pdo->query("SHOW TABLES LIKE 'pf_budget_categories'")->rowCount() > 0;
|
||||||
|
|
||||||
|
if ($tableExists) {
|
||||||
|
// Vérifier si la colonne sort_order existe déjà (sécurité)
|
||||||
|
$columnExists = $pdo->query("SHOW COLUMNS FROM pf_budget_categories LIKE 'sort_order'")->rowCount() > 0;
|
||||||
|
|
||||||
|
if (!$columnExists) {
|
||||||
|
// La colonne n'existe pas, on l'ajoute !
|
||||||
|
$pdo->exec("ALTER TABLE pf_budget_categories ADD sort_order INT DEFAULT 0");
|
||||||
|
echo "<span style='color: #10b981;'>✅ Colonne 'sort_order' ajoutée avec succès !</span>";
|
||||||
|
} else {
|
||||||
|
// La colonne est déjà là
|
||||||
|
echo "<span style='color: #f59e0b;'>ℹ️ La colonne 'sort_order' existe déjà (Ignoré).</span>";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "<span style='color: #ef4444;'>⚠️ La table 'pf_budget_categories' n'existe pas ici.</span>";
|
||||||
|
}
|
||||||
|
echo "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "<h3>🎉 Migration terminée avec succès !</h3>";
|
||||||
|
echo "<p style='color: #64748b;'><em>Tu peux maintenant supprimer ce fichier par mesure de sécurité.</em></p>";
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo "<h3 style='color: #ef4444;'>❌ Erreur fatale MySQL :</h3>";
|
||||||
|
echo "<pre style='background: #fee2e2; padding: 15px; border-radius: 8px;'>" . $e->getMessage() . "</pre>";
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
// On charge l'authentification et l'init PDO de l'espace familial courant automatiquement via tes includes globaux
|
||||||
|
require_once __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require_once __DIR__ . '/../../../../includes/db.php';
|
||||||
|
require_once __DIR__ . '/../../../../includes/i18n.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = trim($_POST['title'] ?? '');
|
||||||
|
$amount = floatval($_POST['amount'] ?? 0);
|
||||||
|
$expected_date = $_POST['expected_date'] ?? '';
|
||||||
|
|
||||||
|
if (empty($title) || $amount <= 0 || empty($expected_date)) {
|
||||||
|
echo json_encode(['success' => false, 'message' => tr('error_generic')]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Grâce au multi-tenant par BDD, $pdo pointe déjà sur la base de la famille connectée
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_expected_expenses (title, amount, expected_date) VALUES (?, ?, ?)");
|
||||||
|
$stmt->execute([$title, $amount, $expected_date]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'message' => tr('success_add_provision')]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require_once __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
$id = intval($_POST['id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Invalid ID']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_expected_expenses WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require_once __DIR__ . '/../../../../includes/db.php';
|
||||||
|
require_once __DIR__ . '/../../../../includes/i18n.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer les provisions non payées, classées par date
|
||||||
|
$stmt = $pdo->query("SELECT id, title, amount, expected_date FROM pf_expected_expenses WHERE is_paid = 0 ORDER BY expected_date ASC");
|
||||||
|
$provisions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (empty($provisions)) {
|
||||||
|
$html = '<p class="pf-text-muted">' . tr('no_provisions') . '</p>';
|
||||||
|
} else {
|
||||||
|
$html = '<table class="pf-table" style="width:100%; border-collapse: collapse;">';
|
||||||
|
$html .= '<thead style="border-bottom: 2px solid var(--border-color); text-align: left;">';
|
||||||
|
$html .= '<tr><th>' . tr('provision_label') . '</th><th>' . tr('amount') . '</th><th>' . tr('expected_date') . '</th><th style="text-align:right;">Actions</th></tr>';
|
||||||
|
$html .= '</thead><tbody>';
|
||||||
|
|
||||||
|
foreach ($provisions as $p) {
|
||||||
|
$dateFormated = date('d/m/Y', strtotime($p['expected_date']));
|
||||||
|
$html .= '<tr style="border-bottom: 1px solid var(--border-color); height: 45px;">';
|
||||||
|
$html .= '<td>' . htmlspecialchars($p['title']) . '</td>';
|
||||||
|
$html .= '<td>' . number_format($p['amount'], 2, ',', ' ') . ' €</td>';
|
||||||
|
$html .= '<td>' . $dateFormated . '</td>';
|
||||||
|
$html .= '<td style="text-align:right;"><button class="pf-btn pf-btn-danger btn-delete-provision" data-id="' . $p['id'] . '">' . tr('btn_delete') . '</button></td>';
|
||||||
|
$html .= '</tr>';
|
||||||
|
}
|
||||||
|
$html .= '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'html' => $html]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
// --- ACTION : SAUVEGARDER (AJOUT OU MODIF DU BUDGET) ---
|
||||||
|
if ($action === 'save') {
|
||||||
|
try {
|
||||||
|
// 1. Sécurisation des données entrantes
|
||||||
|
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
|
||||||
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
$category = $_POST['category'] ?? '';
|
||||||
|
$type = $_POST['type'] ?? 'Mensuel';
|
||||||
|
$payment_day = !empty($_POST['payment_day']) ? (int)$_POST['payment_day'] : null;
|
||||||
|
$is_estimate = isset($_POST['is_estimate']) ? (int)$_POST['is_estimate'] : 0;
|
||||||
|
$reg_month = !empty($_POST['reg_month']) ? trim($_POST['reg_month']) : null;
|
||||||
|
$keywords = trim($_POST['mapping_keywords'] ?? '');
|
||||||
|
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
|
||||||
|
|
||||||
|
// 2. Gestion du montant (toujours stocker les dépenses en négatif)
|
||||||
|
$amount = abs((float)($_POST['amount'] ?? 0));
|
||||||
|
$amount = -$amount; // On force le négatif car le modal Recap ne gère que des charges
|
||||||
|
|
||||||
|
// 3. Exécution de la requête
|
||||||
|
if ($id) {
|
||||||
|
// UPDATE
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_budget_items SET name=?, amount=?, category=?, type=?, payment_day=?, is_estimate=?, reg_month=?, mapping_keywords=?, holiday_id=? WHERE id=?");
|
||||||
|
$stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id, $id]);
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_budget_items (name, amount, category, type, payment_day, is_estimate, reg_month, mapping_keywords, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Réponse appropriée (JSON si appel via JS fetch, Redirection sinon)
|
||||||
|
if (!empty($_POST['ajax'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
header('Location: /budget.php?tab=recap');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// En cas d'erreur BDD (ex: contrainte de clé étrangère, colonne manquante)
|
||||||
|
if (!empty($_POST['ajax'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
die("Erreur fatale : " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTION : COCHER/DÉCOCHER RAPIDE (VIA JS FETCH) ---
|
||||||
|
if ($action === 'toggle-check') {
|
||||||
|
$id = $_POST['id'];
|
||||||
|
$status = $_POST['status'];
|
||||||
|
|
||||||
|
$sql = "UPDATE pf_budget_items SET is_checked = ? WHERE id = ?";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([$status, $id]);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTION : SUPPRIMER UNE LIGNE DU BUDGET ---
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$id = $_POST['id'];
|
||||||
|
$pdo->prepare("DELETE FROM pf_budget_items WHERE id = ?")->execute([$id]);
|
||||||
|
|
||||||
|
if (isset($_POST['ajax'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
header('Location: /budget.php?tab=recap');
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTION : SUPPRIMER UNE DÉPENSE RÉELLE (Depuis l'onglet Suivi) ---
|
||||||
|
if ($action === 'delete_expense') {
|
||||||
|
$id = (int)$_POST['id'];
|
||||||
|
$pdo->prepare("DELETE FROM pf_expenses WHERE id = ?")->execute([$id]);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTION : SAUVEGARDER UNE DÉPENSE MANUELLE (FETCH) ---
|
||||||
|
if ($action === 'save_expense_manual') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$id = !empty($_POST['expense_id']) ? (int)$_POST['expense_id'] : null;
|
||||||
|
$cat = $_POST['category'] ?? '';
|
||||||
|
$amount = floatval($_POST['amount'] ?? 0);
|
||||||
|
$date = $_POST['date'] ?? date('Y-m-d');
|
||||||
|
|
||||||
|
// Format attendu : YYYY-MM
|
||||||
|
$gestionMonthRaw = $_POST['gestion_month'] ?? '';
|
||||||
|
|
||||||
|
// Si la valeur est vide ou invalide
|
||||||
|
if (empty($gestionMonthRaw) || strpos($gestionMonthRaw, '0000') !== false) {
|
||||||
|
$gestionMonth = date('Y-m-01', strtotime($date));
|
||||||
|
} else {
|
||||||
|
$gestionMonth = (strlen($gestionMonthRaw) === 7) ? $gestionMonthRaw . '-01' : $gestionMonthRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = trim($_POST['label'] ?? '');
|
||||||
|
$rawItemId = $_POST['budget_item_id'] ?? '';
|
||||||
|
$budgetItemId = null;
|
||||||
|
$salaryId = null;
|
||||||
|
$holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
|
||||||
|
|
||||||
|
if ($cat === 'School' && !empty($_POST['label_select'])) {
|
||||||
|
$label = trim($_POST['label_select']);
|
||||||
|
} elseif (!empty($rawItemId)) {
|
||||||
|
// 🔥 On intercepte si c'est un Salaire (SAL_x) ou une Charge Fixe classique (x)
|
||||||
|
if (strpos($rawItemId, 'SAL_') === 0) {
|
||||||
|
$salaryId = (int)str_replace('SAL_', '', $rawItemId);
|
||||||
|
} else {
|
||||||
|
$budgetItemId = (int)$rawItemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérification de sécurité
|
||||||
|
if (empty($label) || $amount <= 0) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Le label et un montant supérieur à 0 sont obligatoires.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$is_credit = isset($_POST['is_credit']) ? (int)$_POST['is_credit'] : 0;
|
||||||
|
$finalAmount = $is_credit ? abs($amount) : -abs($amount);
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
// UPDATE (Ajout de salary_id)
|
||||||
|
$pdo->prepare("UPDATE pf_expenses SET date_exp=?, gestion_month=?, category=?, label=?, amount=?, budget_item_id=?, holiday_id=?, salary_id=? WHERE id=?")
|
||||||
|
->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $budgetItemId, $holidayId, $salaryId, $id]);
|
||||||
|
} else {
|
||||||
|
// INSERT (Ajout de salary_id)
|
||||||
|
$uniqueRef = "MANUAL_" . uniqid();
|
||||||
|
$pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id, salary_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||||
|
->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $uniqueRef, $budgetItemId, $holidayId, $salaryId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Erreur base de données.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require_once __DIR__ . '/../../../../includes/db.php';
|
||||||
|
require_once __DIR__ . '/../../../../includes/i18n.php';
|
||||||
|
|
||||||
|
$savings_inputs = $_POST['savings'] ?? [];
|
||||||
|
$current_year = date('Y');
|
||||||
|
$current_month = date('m');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Récupération des dépenses prévues
|
||||||
|
$stmtExpenses = $pdo->prepare("
|
||||||
|
SELECT title, amount, expected_date
|
||||||
|
FROM pf_expected_expenses
|
||||||
|
WHERE is_paid = 0
|
||||||
|
AND YEAR(expected_date) = ?
|
||||||
|
AND MONTH(expected_date) = ?
|
||||||
|
ORDER BY expected_date ASC
|
||||||
|
");
|
||||||
|
$stmtExpenses->execute([$current_year, $current_month]);
|
||||||
|
$expenses = $stmtExpenses->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$q1_expenses_total = 0;
|
||||||
|
$q2_expenses_total = 0;
|
||||||
|
|
||||||
|
$detail_html = '<ul style="padding-left: 1.2rem; margin-bottom: 1rem; color: var(--text-muted); font-size:0.9rem;">';
|
||||||
|
foreach ($expenses as $e) {
|
||||||
|
$day = intval(date('d', strtotime($e['expected_date'])));
|
||||||
|
if ($day <= 15) {
|
||||||
|
$q1_expenses_total += $e['amount'];
|
||||||
|
} else {
|
||||||
|
$q2_expenses_total += $e['amount'];
|
||||||
|
}
|
||||||
|
$detail_html .= '<li>' . htmlspecialchars($e['title']) . ' (' . number_format($e['amount'], 2, ',', ' ') . ' € le ' . date('d/m', strtotime($e['expected_date'])) . ')</li>';
|
||||||
|
}
|
||||||
|
$detail_html .= '</ul>';
|
||||||
|
|
||||||
|
$total_expenses = $q1_expenses_total + $q2_expenses_total;
|
||||||
|
|
||||||
|
// 2. Récupération des dettes
|
||||||
|
$stmtDebts = $pdo->query("SELECT payer, SUM(amount) as total_debt FROM pf_advances WHERE is_resolved = 0 GROUP BY payer");
|
||||||
|
$debts = $stmtDebts->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
// 3. NOUVELLE LOGIQUE : Clearing intelligent (Compensation partielle ou totale)
|
||||||
|
$total_base_inputs = array_sum(array_map('floatval', $savings_inputs));
|
||||||
|
$surplus_for_clearing = $total_base_inputs - $total_expenses;
|
||||||
|
$monthly_savings = 0; // Le vrai total qui sera viré après déduction des dettes
|
||||||
|
|
||||||
|
$contributions_html = '<ul style="padding-left: 1.2rem; margin-bottom: 0.5rem; color: var(--text-main); font-size:0.9rem; line-height: 1.6;">';
|
||||||
|
|
||||||
|
foreach ($savings_inputs as $person => $amount) {
|
||||||
|
$base_amount = floatval($amount);
|
||||||
|
$debt = $debts[$person] ?? 0;
|
||||||
|
|
||||||
|
// Formatage bancaire propre pour les textes
|
||||||
|
$base_fmt = number_format($base_amount, 2, ',', ' ');
|
||||||
|
|
||||||
|
if ($debt > 0) {
|
||||||
|
if ($surplus_for_clearing > 0) {
|
||||||
|
$compensation = min($debt, $base_amount, $surplus_for_clearing);
|
||||||
|
$net_amount = $base_amount - $compensation;
|
||||||
|
$surplus_for_clearing -= $compensation;
|
||||||
|
$monthly_savings += $net_amount;
|
||||||
|
|
||||||
|
$comp_fmt = number_format($compensation, 2, ',', ' ');
|
||||||
|
$net_fmt = number_format($net_amount, 2, ',', ' ');
|
||||||
|
|
||||||
|
$reste_dette = $debt - $compensation;
|
||||||
|
$txt_reste = ($reste_dette > 0)
|
||||||
|
? ' <span style="color:var(--danger); font-size:0.8rem; margin-left: 5px;">(Reste ' . number_format($reste_dette, 2, ',', ' ') . ' € de dette)</span>'
|
||||||
|
: ' <span style="color:var(--success); font-weight:bold; font-size:0.85rem; margin-left: 5px;">(Dette soldée 🎉)</span>';
|
||||||
|
|
||||||
|
// Harmonisation en bleu (#2563eb) pour le montant net à verser
|
||||||
|
$contributions_html .= '<li style="margin-bottom: 5px;"><strong>' . htmlspecialchars($person) . '</strong> : Base ' . $base_fmt . ' € − Remboursé ' . $comp_fmt . ' € = <strong style="color:#2563eb;">' . $net_fmt . ' € à verser</strong>' . $txt_reste . '</li>';
|
||||||
|
} else {
|
||||||
|
$monthly_savings += $base_amount;
|
||||||
|
$contributions_html .= '<li style="margin-bottom: 5px;"><strong>' . htmlspecialchars($person) . '</strong> : <strong style="color:#2563eb;">' . $base_fmt . ' € à verser</strong> <span style="color:var(--danger); font-size:0.8rem; margin-left: 5px;">(Dette gelée, liquidités insuffisantes)</span></li>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$monthly_savings += $base_amount;
|
||||||
|
$contributions_html .= '<li style="margin-bottom: 5px;"><strong>' . htmlspecialchars($person) . '</strong> : <strong style="color:#2563eb;">' . $base_fmt . ' € à verser</strong></li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$contributions_html .= '</ul>';
|
||||||
|
$contributions_html .= '<p style="color: var(--text-main); font-weight: bold; margin-top:5px; font-size:0.95rem;">Total net atterrissant sur les comptes : ' . number_format($monthly_savings, 2, ',', ' ') . ' €</p>';
|
||||||
|
|
||||||
|
if(empty($expenses)) {
|
||||||
|
$detail_html = '<p style="color: var(--success); font-weight:bold; margin-bottom:1rem; font-size:0.9rem;">🎉 Aucune grosse dépense enregistrée sur ce mois.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MOTEUR DE RÈGLES DES QUINZAINES (Remplacement des ** par <strong>) ---
|
||||||
|
$instructions = [];
|
||||||
|
$remaining_to_allocate = $monthly_savings;
|
||||||
|
|
||||||
|
// Règle 1 : Première quinzaine
|
||||||
|
if ($q1_expenses_total > 0) {
|
||||||
|
if ($remaining_to_allocate >= $q1_expenses_total) {
|
||||||
|
$remaining_to_allocate -= $q1_expenses_total;
|
||||||
|
$instructions[] = "💼 <strong>Dès le 1er</strong> : Laissez <strong>" . number_format($q1_expenses_total, 2, ',', ' ') . " €</strong> sur le compte commun pour honorer les dépenses de la 1ère quinzaine.";
|
||||||
|
} else {
|
||||||
|
$deficit = $q1_expenses_total - $remaining_to_allocate;
|
||||||
|
$remaining_to_allocate = 0;
|
||||||
|
$instructions[] = "💼 <strong>Dès le 1er</strong> : Gardez l'intégralité des apports sur le compte commun.";
|
||||||
|
$instructions[] = "⚠️ <strong>Retrait requis</strong> : Retirez <strong>" . number_format($deficit, 2, ',', ' ') . " €</strong> depuis le Livret A vers le commun pour couvrir la 1ère quinzaine.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Règle 2 : Deuxième quinzaine
|
||||||
|
if ($q2_expenses_total > 0) {
|
||||||
|
if ($remaining_to_allocate >= $q2_expenses_total) {
|
||||||
|
$remaining_to_allocate -= $q2_expenses_total;
|
||||||
|
$instructions[] = "📈 <strong>Dès le 1er</strong> : Placez <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> sur le Livret A pour générer des intérêts sur la 1ère quinzaine.";
|
||||||
|
$instructions[] = "🔄 <strong>Le 16 du mois</strong> : Transférez ces <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> sur le compte commun pour payer la dépense.";
|
||||||
|
} else {
|
||||||
|
if ($remaining_to_allocate > 0) {
|
||||||
|
$instructions[] = "📈 <strong>Dès le 1er</strong> : Placez le reste des apports (<strong>" . number_format($remaining_to_allocate, 2, ',', ' ') . " €</strong>) sur le Livret A.";
|
||||||
|
$instructions[] = "🔄 <strong>Le 16 du mois</strong> : Retirez <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> global du Livret A pour payer la fin de mois.";
|
||||||
|
$remaining_to_allocate = 0;
|
||||||
|
} else {
|
||||||
|
$instructions[] = "🔄 <strong>Le 16 du mois</strong> : Retirez <strong>" . number_format($q2_expenses_total, 2, ',', ' ') . " €</strong> du Livret A vers le commun. <em>Ne le faites pas avant le 16 !</em>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solde résiduel long terme
|
||||||
|
if ($remaining_to_allocate > 0) {
|
||||||
|
$instructions[] = "💰 <strong>Épargne stable</strong> : Placez les <strong>" . number_format($remaining_to_allocate, 2, ',', ' ') . " €</strong> restants sur votre Livret A dès le 1er.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CONSTITUTION DU RENDU HTML FINAL ---
|
||||||
|
$html = '<div style="border-top: 1px dashed var(--border-color); padding-top:0.8rem;">';
|
||||||
|
|
||||||
|
// Titre de l'état du Clearing simplifié et clair
|
||||||
|
$html .= '<h4 style="margin:0 0 0.4rem 0; font-size:0.95rem;">🤝 Résumé des apports et remboursements internes :</h4>';
|
||||||
|
$html .= '<div style="background: var(--bg-main); padding: 8px; border-radius: 6px; margin-bottom: 1rem; border: 1px solid var(--border-color);">';
|
||||||
|
$html .= $contributions_html;
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
$html .= '<h4 style="margin:0 0 0.4rem 0; font-size:0.95rem;">Analyse des dépenses du mois :</h4>';
|
||||||
|
$html .= $detail_html;
|
||||||
|
|
||||||
|
$html .= '<div style="background: var(--bg-main); padding: 8px; border-radius: 6px; border-left: 4px solid var(--accent-color);">';
|
||||||
|
$html .= '<h4 style="margin:0 0 0.5rem 0; color: var(--accent-color); font-size:0.95rem;">📋 Plan d\'action recommandé :</h4>';
|
||||||
|
|
||||||
|
foreach ($instructions as $ins) {
|
||||||
|
$html .= '<p style="margin:0 0 0.4rem 0; font-size: 0.9rem; line-height: 1.4;">' . $ins . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div></div>';
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'html' => $html]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Calcul impossible : ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<?php
|
||||||
|
// modules/budget/includes/api/save-budget.php
|
||||||
|
|
||||||
|
require __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// LECTURE D'UNE NOTE (AJAX)
|
||||||
|
// =================================================================
|
||||||
|
if ($action === 'get_note') {
|
||||||
|
$noteType = $_POST['note_type'] ?? '';
|
||||||
|
$refId = $_POST['reference_id'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = ? AND reference_id = ?");
|
||||||
|
$stmt->execute([$noteType, $refId]);
|
||||||
|
|
||||||
|
// fetchColumn() renvoie false si aucune ligne n'est trouvée, on sécurise avec un coalesce (?? '')
|
||||||
|
$content = $stmt->fetchColumn();
|
||||||
|
$content = $content !== false ? $content : '';
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'content' => $content]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 7. SAUVEGARDE D'UNE NOTE GÉNÉRIQUE (pf_notes)
|
||||||
|
// =================================================================
|
||||||
|
if ($action === 'save_note') {
|
||||||
|
// On affiche les erreurs s'il y a un souci SQL pour pouvoir déboguer
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$noteType = $_POST['note_type'] ?? '';
|
||||||
|
$refId = $_POST['reference_id'] ?? '';
|
||||||
|
$content = $_POST['content'] ?? '';
|
||||||
|
|
||||||
|
if (empty($noteType) || empty($refId)) {
|
||||||
|
throw new Exception("Le type et la référence de la note sont requis.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insère ou met à jour la note si elle existe déjà
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE content = VALUES(content)");
|
||||||
|
$stmt->execute([$noteType, $refId, $content]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Erreur SQL: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
// 1. MISE A JOUR TABLEAU SALAIRES (AJAX)
|
||||||
|
if ($action === 'update_salary_config') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$year = $_POST['year'];
|
||||||
|
$person = $_POST['person'];
|
||||||
|
$field = $_POST['field']; // salary, mensualite, etc.
|
||||||
|
$value = floatval($_POST['value']);
|
||||||
|
|
||||||
|
// Liste des champs autorisés pour éviter les injections
|
||||||
|
$allowed = ['salary', 'mensualite', 'frais_func', 'eco_perso', 'eco_family'];
|
||||||
|
if (!in_array($field, $allowed)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Champ invalide']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_salary_config (year, person, $field) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE $field = VALUES($field)");
|
||||||
|
$stmt->execute([$year, $person, $value]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. MISE A JOUR TABLEAU REPARTITION (AJAX)
|
||||||
|
if ($action === 'update_allocation') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$date = $_POST['month_date'];
|
||||||
|
$catId = (int)$_POST['cat_id'];
|
||||||
|
$personId = (int)$_POST['person_id'];
|
||||||
|
$value = floatval($_POST['value']);
|
||||||
|
|
||||||
|
if ($catId <= 0 || $personId <= 0) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Données invalides.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, person_id, amount)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE amount = VALUES(amount)");
|
||||||
|
$stmt->execute([$date, $catId, $personId, $value]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. GESTION DES CATEGORIES (Ajout)
|
||||||
|
if ($action === 'add_category') {
|
||||||
|
try {
|
||||||
|
$name = trim($_POST['name']);
|
||||||
|
$transfer_dest = trim($_POST['transfer_dest'] ?? '');
|
||||||
|
$target = floatval($_POST['target'] ?? 0);
|
||||||
|
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
|
||||||
|
|
||||||
|
if (!empty($name)) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, transfer_dest, holiday_id) VALUES (?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$name, $target, $transfer_dest, $holiday_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_POST['ajax'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
header("Location: " . $_SERVER['HTTP_REFERER']);
|
||||||
|
exit;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if (isset($_POST['ajax'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
die($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. MODIFICATION D'UNE CATEGORIE
|
||||||
|
if ($action === 'update_category') {
|
||||||
|
try {
|
||||||
|
$id = (int)$_POST['cat_id'];
|
||||||
|
$name = trim($_POST['name']);
|
||||||
|
$transfer_dest = trim($_POST['transfer_dest'] ?? '');
|
||||||
|
$target = floatval($_POST['target'] ?? 0);
|
||||||
|
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
|
||||||
|
|
||||||
|
if ($id > 0 && !empty($name)) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_alloc_categories SET name = ?, target = ?, transfer_dest = ?, holiday_id = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$name, $target, $transfer_dest, $holiday_id, $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_POST['ajax'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
header("Location: " . $_SERVER['HTTP_REFERER']);
|
||||||
|
exit;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if (isset($_POST['ajax'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
die($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. SUPPRESSION CATEGORIE
|
||||||
|
if ($action === 'delete_category') {
|
||||||
|
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($id > 0) {
|
||||||
|
$pdo->prepare("DELETE FROM pf_alloc_categories WHERE id = ?")->execute([$id]);
|
||||||
|
$pdo->prepare("DELETE FROM pf_alloc_values WHERE cat_id = ?")->execute([$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_POST['ajax']) || isset($_GET['ajax'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
header("Location: " . ($_SERVER['HTTP_REFERER'] ?? '/budget.php'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. VALIDATION DES VIREMENTS (Complex Business Logic)
|
||||||
|
if ($action === 'validate_transfers') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$personId = (int)$_POST['person_id']; // Reçoit l'ID parent
|
||||||
|
$monthDate = $_POST['month_date'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
$stmtP = $pdo->prepare("SELECT name FROM pf_people WHERE id = ?");
|
||||||
|
$stmtP->execute([$personId]);
|
||||||
|
$dbPersonName = $stmtP->fetchColumn();
|
||||||
|
if (!$dbPersonName) throw new Exception("Parent introuvable.");
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT v.amount, c.name as cat_name, c.transfer_dest, c.holiday_id
|
||||||
|
FROM pf_alloc_values v
|
||||||
|
JOIN pf_alloc_categories c ON v.cat_id = c.id
|
||||||
|
WHERE v.month_date = ? AND v.person_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$monthDate, $personId]);
|
||||||
|
$budgetLines = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$transfersToDo = [];
|
||||||
|
|
||||||
|
foreach ($budgetLines as $line) {
|
||||||
|
$amount = (float)$line['amount'];
|
||||||
|
if ($amount <= 0) continue;
|
||||||
|
|
||||||
|
$dest = trim($line['transfer_dest']);
|
||||||
|
$catName = trim($line['cat_name']);
|
||||||
|
$holidayId = $line['holiday_id'];
|
||||||
|
|
||||||
|
$targetOwner = null;
|
||||||
|
if ($dest === 'vers L.Perso') { $targetOwner = $dbPersonName; }
|
||||||
|
elseif ($dest === 'vers L.Pol') { $targetOwner = 'Pol'; }
|
||||||
|
elseif ($dest === 'vers L.Pep') { $targetOwner = 'Pep'; }
|
||||||
|
elseif ($dest === 'vers commune') { continue; }
|
||||||
|
|
||||||
|
if ($targetOwner) {
|
||||||
|
if (!isset($transfersToDo[$targetOwner])) {
|
||||||
|
$transfersToDo[$targetOwner] = ['total_add' => 0, 'cats' => []];
|
||||||
|
}
|
||||||
|
$transfersToDo[$targetOwner]['total_add'] += $amount;
|
||||||
|
|
||||||
|
if (!isset($transfersToDo[$targetOwner]['cats'][$catName])) {
|
||||||
|
$transfersToDo[$targetOwner]['cats'][$catName] = ['amount' => 0, 'holiday_id' => $holidayId];
|
||||||
|
}
|
||||||
|
$transfersToDo[$targetOwner]['cats'][$catName]['amount'] += $amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($transfersToDo as $owner => $data) {
|
||||||
|
$stmtCheck = $pdo->prepare("SELECT COUNT(*) FROM pf_savings WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'");
|
||||||
|
$stmtCheck->execute([$owner, $monthDate]);
|
||||||
|
$exists = $stmtCheck->fetchColumn() > 0;
|
||||||
|
|
||||||
|
if (!$exists) {
|
||||||
|
$prevDate = date('Y-m-d', strtotime($monthDate . ' -1 month'));
|
||||||
|
$stmtPrev = $pdo->prepare("SELECT category, amount, holiday_id FROM pf_savings WHERE owner = ? AND month_date = ?");
|
||||||
|
$stmtPrev->execute([$owner, $prevDate]);
|
||||||
|
$prevLines = $stmtPrev->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (empty($prevLines)) { $prevLines = [['category' => 'TOTAL_BANQUE', 'amount' => 0, 'holiday_id' => null]]; }
|
||||||
|
|
||||||
|
$stmtInsert = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
foreach ($prevLines as $row) {
|
||||||
|
$stmtInsert->execute([$monthDate, $owner, $row['category'], $row['amount'], $row['holiday_id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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]);
|
||||||
|
|
||||||
|
foreach ($data['cats'] as $catName => $catInfo) {
|
||||||
|
$catAmount = $catInfo['amount'];
|
||||||
|
$catHolidayId = $catInfo['holiday_id'];
|
||||||
|
|
||||||
|
if (strpos($catName, 'Eco P') === 0) { continue; }
|
||||||
|
|
||||||
|
$stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
|
||||||
|
$stmtCheckCat->execute([$owner, $monthDate, $catName]);
|
||||||
|
$catId = $stmtCheckCat->fetchColumn();
|
||||||
|
|
||||||
|
if ($catId) {
|
||||||
|
$stmtUpdateCat = $pdo->prepare("UPDATE pf_savings SET amount = amount + ?, holiday_id = ? WHERE id = ?");
|
||||||
|
$stmtUpdateCat->execute([$catAmount, $catHolidayId, $catId]);
|
||||||
|
} else {
|
||||||
|
$stmtInsertCat = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmtInsertCat->execute([$monthDate, $owner, $catName, $catAmount, $catHolidayId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmtSys = $pdo->prepare("SELECT id FROM pf_alloc_categories WHERE name = 'SYSTEM_VALIDATION' LIMIT 1");
|
||||||
|
$stmtSys->execute();
|
||||||
|
$sysCatId = $stmtSys->fetchColumn();
|
||||||
|
|
||||||
|
if ($sysCatId) {
|
||||||
|
$stmtVal = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, person_id, amount)
|
||||||
|
VALUES (?, ?, ?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE amount = 1");
|
||||||
|
$stmtVal->execute([$monthDate, $sysCatId, $personId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 7. GESTION DES AVANCES / TRICOUNT (pf_advances)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
if ($action === 'save_advance') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
try {
|
||||||
|
$payer = trim($_POST['payer'] ?? '');
|
||||||
|
$advance_date = $_POST['advance_date'] ?? date('Y-m-d');
|
||||||
|
$description = trim($_POST['description'] ?? '');
|
||||||
|
$amount = abs((float)($_POST['amount'] ?? 0));
|
||||||
|
$from_savings = isset($_POST['from_savings']) ? 1 : 0;
|
||||||
|
|
||||||
|
if (empty($payer) || empty($description) || $amount <= 0) {
|
||||||
|
throw new Exception("Champs obligatoires manquants ou invalides.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_advances (advance_date, payer, description, amount, from_savings) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$advance_date, $payer, $description, $amount, $from_savings]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'update_advance') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
try {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$payer = trim($_POST['payer'] ?? '');
|
||||||
|
$advance_date = $_POST['advance_date'] ?? date('Y-m-d');
|
||||||
|
$description = trim($_POST['description'] ?? '');
|
||||||
|
$amount = abs((float)($_POST['amount'] ?? 0));
|
||||||
|
$from_savings = isset($_POST['from_savings']) ? 1 : 0;
|
||||||
|
|
||||||
|
if ($id <= 0 || empty($payer) || empty($description) || $amount <= 0) {
|
||||||
|
throw new Exception("Paramètres invalides fournis.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_advances SET advance_date = ?, payer = ?, description = ?, amount = ?, from_savings = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$advance_date, $payer, $description, $amount, $from_savings, $id]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'resolve_advance') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
try {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
if ($id <= 0) throw new Exception("ID invalide fourni.");
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_advances SET is_resolved = 1 WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_advance') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
try {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
if ($id <= 0) throw new Exception("ID invalide.");
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_advances WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
// 1. Déclaration propre des variables d'entrée
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
$isAjax = !empty($_POST['ajax']);
|
||||||
|
|
||||||
|
// Si c'est de l'AJAX, on force les en-têtes HTTP pour garantir du JSON
|
||||||
|
if ($isAjax) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// MISE À JOUR D'UNE CELLULE EN DIRECT (AJAX)
|
||||||
|
// =================================================================
|
||||||
|
if ($action === 'update_single_entry') {
|
||||||
|
$month = $_POST['month_date'] ?? '';
|
||||||
|
$cat = $_POST['category'] ?? '';
|
||||||
|
$owner = $_POST['owner'] ?? '';
|
||||||
|
$amount = (float)($_POST['amount'] ?? 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($amount == 0 && $cat !== 'TOTAL_BANQUE') {
|
||||||
|
// Si on met à 0 une ligne (autre que le total), on supprime l'entrée pour garder la base propre
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE month_date=? AND owner=? AND category=?");
|
||||||
|
$stmt->execute([$month, $owner, $cat]);
|
||||||
|
} else {
|
||||||
|
// Sinon on insère ou on met à jour
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE amount = VALUES(amount)");
|
||||||
|
$stmt->execute([$month, $owner, $cat, $amount]);
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTION : SUPPRESSION D'UNE ENTRÉE UNIQUE ---
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'delete_entry') {
|
||||||
|
$owner = $_POST['owner'] ?? '';
|
||||||
|
$date = $_POST['month_date'] ?? '';
|
||||||
|
$cat = $_POST['category'] ?? '';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
|
||||||
|
$stmt->execute([$owner, $date, $cat]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTION : DUPLICATION DE MOIS ---
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'duplicate_month') {
|
||||||
|
$owner = $_POST['owner'] ?? '';
|
||||||
|
$sourceDate = $_POST['source_date'] ?? '';
|
||||||
|
$targetDate = $_POST['target_date'] ?? '';
|
||||||
|
$newTotal = floatval($_POST['new_total'] ?? 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
// 1. Vérifier si le mois cible existe déjà
|
||||||
|
$check = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? LIMIT 1");
|
||||||
|
$check->execute([$owner, $targetDate]);
|
||||||
|
if ($check->fetch()) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ce mois existe déjà !']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Insérer le nouveau TOTAL BANQUE
|
||||||
|
$stmtIns = $pdo->prepare("INSERT INTO pf_savings (owner, month_date, category, amount) VALUES (?, ?, ?, ?)");
|
||||||
|
$stmtIns->execute([$owner, $targetDate, 'TOTAL_BANQUE', $newTotal]);
|
||||||
|
|
||||||
|
// 3. Copier toutes les catégories (sauf TOTAL_BANQUE) du mois source
|
||||||
|
$sqlCopy = "INSERT INTO pf_savings (owner, month_date, category, amount)
|
||||||
|
SELECT owner, ?, category, amount
|
||||||
|
FROM pf_savings
|
||||||
|
WHERE owner = ? AND month_date = ? AND category != 'TOTAL_BANQUE'";
|
||||||
|
|
||||||
|
$stmtCopy = $pdo->prepare($sqlCopy);
|
||||||
|
$stmtCopy->execute([$targetDate, $owner, $sourceDate]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTION : SUPPRESSION GLOBALE D'UN MOIS ---
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'delete_month_global') {
|
||||||
|
$owner = $_POST['owner'] ?? '';
|
||||||
|
$date = $_POST['month_date'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ?");
|
||||||
|
$stmt->execute([$owner, $date]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTION : SAUVEGARDE CLASSIQUE (MODALE) ---
|
||||||
|
// Cette action s'exécute si c'est un POST général sans action précise (le form HTML) ou l'action explicitement nommée.
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($action, ['save_modal', ''])) {
|
||||||
|
$owner = $_POST['owner'] ?? '';
|
||||||
|
$redirectTab = $_POST['redirect_tab'] ?? $owner;
|
||||||
|
|
||||||
|
$dateInput = $_POST['month_date'] ?? '';
|
||||||
|
$values = $_POST['values'] ?? []; // Tableau généré par nos champs JS: values[Catégorie]
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dateObj = new DateTime($dateInput);
|
||||||
|
$monthDate = $dateObj->format('Y-m-01');
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
// On supprime d'abord les anciennes données du mois pour cet utilisateur
|
||||||
|
$stmtDel = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ?");
|
||||||
|
$stmtDel->execute([$owner, $monthDate]);
|
||||||
|
|
||||||
|
// On réinsère les nouvelles données
|
||||||
|
$stmtIns = $pdo->prepare("INSERT INTO pf_savings (owner, month_date, category, amount) VALUES (?, ?, ?, ?)");
|
||||||
|
foreach ($values as $category => $amount) {
|
||||||
|
$amount = floatval($amount);
|
||||||
|
// On enregistre si c'est positif OU si c'est le total banque
|
||||||
|
if ($amount > 0 || $category === 'TOTAL_BANQUE') {
|
||||||
|
$stmtIns->execute([$owner, $monthDate, $category, $amount]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
// On répond proprement selon le contexte (AJAX ou Fallback classique)
|
||||||
|
if ($isAjax) {
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
header("Location: /budget.php?tab=epargne&owner=" . urlencode($redirectTab));
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
|
||||||
|
if ($isAjax) {
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
} else {
|
||||||
|
die("Erreur : " . $e->getMessage());
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sécurité finale : Si l'action n'est pas reconnue
|
||||||
|
if ($isAjax) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Action inconnue']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($action === 'get_all') {
|
||||||
|
// 1. Comptes bancaires avec le nom du propriétaire lié (pf_people)
|
||||||
|
$accounts = $pdo->query("
|
||||||
|
SELECT a.*, p.name as owner_name
|
||||||
|
FROM pf_bank_accounts a
|
||||||
|
LEFT JOIN pf_people p ON a.owner_person_id = p.id
|
||||||
|
ORDER BY a.is_default DESC, a.name ASC
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// 2. Catégories
|
||||||
|
$categories = $pdo->query("SELECT * FROM pf_budget_categories ORDER BY type ASC, label ASC")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// 3. Règles d'import
|
||||||
|
$rules = $pdo->query("
|
||||||
|
SELECT r.*, c.label as cat_label
|
||||||
|
FROM pf_import_rules r
|
||||||
|
LEFT JOIN pf_budget_categories c ON r.category COLLATE utf8mb4_unicode_ci = c.code COLLATE utf8mb4_unicode_ci
|
||||||
|
ORDER BY r.keyword ASC
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// 4. Salaires (Année en cours)
|
||||||
|
$currentYear = (int)date('Y');
|
||||||
|
$salaries = $pdo->query("
|
||||||
|
SELECT p.name as person,
|
||||||
|
COALESCE(s.salary, 0) as salary,
|
||||||
|
COALESCE(s.mensualite, 0) as mensualite,
|
||||||
|
s.id as id
|
||||||
|
FROM pf_people p
|
||||||
|
LEFT JOIN pf_salary_config s ON p.name = s.person AND s.year = $currentYear
|
||||||
|
WHERE p.role NOT IN ('enfant', 'nounou') AND p.is_active = 1
|
||||||
|
ORDER BY p.name ASC
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// 5. Paramètres Foyer (Devise + Mapping CSV)
|
||||||
|
$foyerData = $pdo->query("SELECT currency, csv_mapping FROM pf_foyer_settings LIMIT 1")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$currencySetting = $foyerData['currency'] ?? '€';
|
||||||
|
$csvMapping = !empty($foyerData['csv_mapping']) ? json_decode($foyerData['csv_mapping'], true) : null;
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'accounts' => $accounts,
|
||||||
|
'categories' => $categories,
|
||||||
|
'rules' => $rules,
|
||||||
|
'salaries' => $salaries,
|
||||||
|
'year' => $currentYear,
|
||||||
|
'currency' => $currencySetting,
|
||||||
|
'csv_mapping' => $csvMapping
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GESTION DES COMPTES BANCAIRES ---
|
||||||
|
if ($action === 'add_account') {
|
||||||
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
$type = $_POST['type'] ?? 'checking';
|
||||||
|
if (empty($name)) throw new Exception("Le nom du compte est obligatoire.");
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_bank_accounts (name, account_type, is_default) VALUES (?, ?, 0)");
|
||||||
|
$stmt->execute([$name, $type]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_account') {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$count = $pdo->query("SELECT COUNT(*) FROM pf_bank_accounts")->fetchColumn();
|
||||||
|
if ($count <= 1) throw new Exception("Impossible de supprimer le dernier compte.");
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_bank_accounts WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GESTION DES CATÉGORIES ---
|
||||||
|
if ($action === 'add_category') {
|
||||||
|
$code = strtoupper(trim($_POST['code'] ?? ''));
|
||||||
|
$label = trim($_POST['label'] ?? '');
|
||||||
|
$type = $_POST['type'] ?? 'Expense';
|
||||||
|
$color = $_POST['color'] ?? '#cccccc';
|
||||||
|
$icon = trim($_POST['icon'] ?? '📌');
|
||||||
|
if (empty($code) || empty($label)) throw new Exception("Le code et le libellé sont obligatoires.");
|
||||||
|
$check = $pdo->prepare("SELECT COUNT(*) FROM pf_budget_categories WHERE code = ?");
|
||||||
|
$check->execute([$code]);
|
||||||
|
if ($check->fetchColumn() > 0) throw new Exception("Ce code de catégorie existe déjà.");
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_budget_categories (code, label, type, color, icon) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$code, $label, $type, $color, $icon]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_category') {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_budget_categories WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GESTION DES RÈGLES D'IMPORT ---
|
||||||
|
if ($action === 'add_rule') {
|
||||||
|
$keyword = strtoupper(trim($_POST['keyword'] ?? ''));
|
||||||
|
$category = trim($_POST['category'] ?? '');
|
||||||
|
if (empty($keyword) || empty($category)) throw new Exception("Le mot-clé et la catégorie sont obligatoires.");
|
||||||
|
$check = $pdo->prepare("SELECT COUNT(*) FROM pf_import_rules WHERE keyword = ?");
|
||||||
|
$check->execute([$keyword]);
|
||||||
|
if ($check->fetchColumn() > 0) throw new Exception("Une règle pour ce mot-clé existe déjà.");
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category) VALUES (?, ?)");
|
||||||
|
$stmt->execute([$keyword, $category]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_rule') {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_import_rules WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GESTION DES SALAIRES ---
|
||||||
|
if ($action === 'save_salary') {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$salary = (float)($_POST['salary'] ?? 0);
|
||||||
|
$mensualite = (float)($_POST['mensualite'] ?? 0);
|
||||||
|
if ($id <= 0) throw new Exception("ID de configuration de salaire invalide.");
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_salary_config SET salary = ?, mensualite = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$salary, $mensualite, $id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GESTION DE LA DEVISE GLOBALE ---
|
||||||
|
if ($action === 'save_currency') {
|
||||||
|
$currency = trim($_POST['currency'] ?? '€');
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_foyer_settings SET currency = ?");
|
||||||
|
$stmt->execute([$currency]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GESTION DU FORMAT CSV ---
|
||||||
|
if ($action === 'save_csv_mapping') {
|
||||||
|
$mapping = [
|
||||||
|
'delimiter' => $_POST['csv_delimiter'] ?? ';',
|
||||||
|
'date_format' => $_POST['csv_date_format'] ?? 'd/m/Y',
|
||||||
|
'col_date' => (int)($_POST['csv_col_date'] ?? 0),
|
||||||
|
'col_label' => (int)($_POST['csv_col_label'] ?? 1),
|
||||||
|
'amount_type' => $_POST['csv_amount_type'] ?? 'single',
|
||||||
|
'col_debit' => (int)($_POST['csv_col_debit'] ?? 8),
|
||||||
|
'col_credit' => (int)($_POST['csv_col_credit'] ?? 9),
|
||||||
|
'col_ref' => (int)($_POST['csv_col_ref'] ?? 3)
|
||||||
|
];
|
||||||
|
$jsonContent = json_encode($mapping);
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_foyer_settings SET csv_mapping = ?");
|
||||||
|
$stmt->execute([$jsonContent]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAUVEGARDE DU NOUVEL ORDRE DES CATÉGORIES
|
||||||
|
if (isset($_POST['action']) && $_POST['action'] === 'save_categories_order') {
|
||||||
|
$ids = json_decode($_POST['order'], true);
|
||||||
|
if (is_array($ids)) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_budget_categories SET sort_order = ? WHERE id = ?");
|
||||||
|
foreach ($ids as $index => $id) {
|
||||||
|
$stmt->execute([$index, (int)$id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("Action API inconnue.");
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
<?php
|
||||||
|
// modules/budget/views/epargne.php
|
||||||
|
|
||||||
|
// --- 🧠 CONFIGURATION AGNOSTIQUE DES MEMBRES (MULTI-TENANT) ---
|
||||||
|
$stmtPeople = $pdo->query("SELECT name, role FROM pf_people ORDER BY id ASC");
|
||||||
|
$familyParents = [];
|
||||||
|
$familyKids = [];
|
||||||
|
|
||||||
|
while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$role = strtolower(trim($row['role'] ?? ''));
|
||||||
|
|
||||||
|
if ($role === 'parent') {
|
||||||
|
$familyParents[] = $row['name'];
|
||||||
|
} elseif ($role === 'nounou' || $role === 'helper') {
|
||||||
|
// On ignore la nounou dans l'épargne
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
$familyKids[] = $row['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sécurité anti-page blanche si la BDD est mal configurée
|
||||||
|
if (empty($familyParents)) $familyParents = ['Parent 1', 'Parent 2'];
|
||||||
|
|
||||||
|
$requestedOwner = $_GET['owner'] ?? (!empty($familyKids) ? 'KIDS' : ($familyParents[0] ?? 'KIDS'));
|
||||||
|
$ownersToDisplay = ($requestedOwner === 'KIDS') ? $familyKids : [$requestedOwner];
|
||||||
|
|
||||||
|
// --- RÉCUPÉRATION CONFIGURATION DES MOIS ---
|
||||||
|
$cycleConfigs = [];
|
||||||
|
$stmtNotes = $pdo->query("SELECT reference_id, content FROM pf_notes WHERE note_type = 'month_config'");
|
||||||
|
while ($row = $stmtNotes->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$parts = explode('-', $row['reference_id']);
|
||||||
|
if (count($parts) == 2) {
|
||||||
|
$mKey = $parts[1] . '-' . $parts[0] . '-01';
|
||||||
|
$cycleConfigs[$mKey] = json_decode($row['content'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthName($dateString) {
|
||||||
|
$m = date('m', strtotime($dateString));
|
||||||
|
$y = date('Y', strtotime($dateString));
|
||||||
|
return tr('month_' . $m) . ' ' . $y;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="budget-view">
|
||||||
|
<div class="view-header">
|
||||||
|
<div class="owner-tabs">
|
||||||
|
|
||||||
|
<?php if (!empty($familyKids)): ?>
|
||||||
|
<a href="?tab=epargne&owner=KIDS" class="owner-tab <?= $requestedOwner === 'KIDS' ? 'active' : '' ?>">
|
||||||
|
<?= tr('budget_tab_kids') ?? 'Enfants 👶' ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php foreach ($familyParents as $p): ?>
|
||||||
|
<a href="?tab=epargne&owner=<?= urlencode($p) ?>" class="owner-tab <?= $requestedOwner === $p ? 'active' : '' ?>">
|
||||||
|
<?= htmlspecialchars($p) ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php foreach ($ownersToDisplay as $index => $currentOwner):
|
||||||
|
$stmt = $pdo->prepare("SELECT month_date, category, amount FROM pf_savings WHERE owner = ? ORDER BY month_date DESC");
|
||||||
|
$stmt->execute([$currentOwner]);
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
$months = [];
|
||||||
|
$allCategories = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$m = $row['month_date'];
|
||||||
|
$cat = $row['category'];
|
||||||
|
$val = $row['amount'];
|
||||||
|
$data[$m][$cat] = $val;
|
||||||
|
if (!in_array($m, $months)) $months[] = $m;
|
||||||
|
if ($cat !== 'TOTAL_BANQUE' && !in_array($cat, $allCategories)) $allCategories[] = $cat;
|
||||||
|
}
|
||||||
|
$months = array_slice($months, 0, 7);
|
||||||
|
sort($allCategories);
|
||||||
|
|
||||||
|
$ownerTextClass = 'txt-global';
|
||||||
|
// 🔄 CORRECTION ICI : Création d'un nom "safe" sans espace pour les classes CSS
|
||||||
|
$safeOwnerCls = htmlspecialchars(str_replace(' ', '_', $currentOwner), ENT_QUOTES);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; margin-top: <?= ($requestedOwner === 'KIDS' && $index > 0) ? '40px' : '0' ?>;">
|
||||||
|
<div style="flex-grow: 1;">
|
||||||
|
<?php if ($requestedOwner === 'KIDS'):
|
||||||
|
$themeClass = 'theme-' . strtolower($safeOwnerCls);
|
||||||
|
?>
|
||||||
|
<h3 class="nens-title <?= $themeClass ?>" style="margin:0; font-size:1.2rem;">
|
||||||
|
<?= htmlspecialchars($currentOwner) ?>
|
||||||
|
</h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
<?php if (!empty($months)): ?>
|
||||||
|
<button onclick="duplicateLastMonth('<?= $months[0] ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn btn-secondary">
|
||||||
|
🔁 <?= tr('bud_sav_add_one_month') ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<button onclick="openCustomSavingsModal('<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')" class="pf-btn">
|
||||||
|
+ <?= tr('bud_sav_add_month') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive" style="background:white; border-radius:16px; box-shadow:var(--shadow-sm); border:1px solid #e2e8f0;">
|
||||||
|
<?php if (empty($months)): ?>
|
||||||
|
<div style="padding: 30px; text-align: center; color: #64748b;">
|
||||||
|
<p><?= sprintf(tr('bud_sav_no_data'), htmlspecialchars($currentOwner)) ?></p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<table class="pf-table savings-table nens-table theme-<?= strtolower($safeOwnerCls) ?>" style="margin-top:0; box-shadow:none; border-radius:16px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sticky-col" style="background:#f8fafc;"><?= tr('bud_sav_post_month') ?></th>
|
||||||
|
<?php foreach ($months as $month): ?>
|
||||||
|
<th>
|
||||||
|
<div class="month-header-container" style="display: flex; flex-direction: column; align-items: center; gap: 6px;">
|
||||||
|
<div style="display:flex; flex-direction:column; text-align:center;">
|
||||||
|
<span class="month-name" style="text-transform:capitalize;"><?= getMonthName($month) ?></span>
|
||||||
|
<?php
|
||||||
|
if (isset($cycleConfigs[$month]) && !empty($cycleConfigs[$month]['start_date'])) {
|
||||||
|
$cStart = date('d/m', strtotime($cycleConfigs[$month]['start_date']));
|
||||||
|
echo "<span style='font-size:0.75rem; font-weight:normal; color:#64748b;'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</span>";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<div class="month-actions" style="justify-content: center; width: 100%;">
|
||||||
|
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_edit_modal') ?>"
|
||||||
|
data-json="<?= htmlspecialchars(json_encode($data[$month] ?? []), ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
onclick='editCustomSavingsMonth("<?= $month ?>", "<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>", JSON.parse(this.getAttribute("data-json")))'>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_delete_month') ?>"
|
||||||
|
onclick="deleteEntireMonth('<?= $month ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>')"
|
||||||
|
style="color: #ef4444; border-color: #fca5a5; background: #fef2f2;">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="row-total">
|
||||||
|
<td class="sticky-col"><strong><?= tr('bud_sav_total_bank') ?></strong></td>
|
||||||
|
<?php foreach ($months as $month):
|
||||||
|
$val = $data[$month]['TOTAL_BANQUE'] ?? 0;
|
||||||
|
?>
|
||||||
|
<td class="text-center" style="padding:4px;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
|
||||||
|
<input type="number" step="0.01"
|
||||||
|
class="prev-input total-input-<?= $safeOwnerCls ?>-<?= $month ?>"
|
||||||
|
style="width: 70px; font-weight:bold; color:#2563eb;"
|
||||||
|
value="<?= $val != 0 ? round($val) : '' ?>"
|
||||||
|
placeholder="0"
|
||||||
|
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
|
||||||
|
<span style="color:#2563eb; font-weight:bold; font-size:0.9rem;">€</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<?php foreach ($allCategories as $cat): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="sticky-col"><?= htmlspecialchars($cat) ?></td>
|
||||||
|
<?php foreach ($months as $month):
|
||||||
|
$amount = $data[$month][$cat] ?? 0;
|
||||||
|
?>
|
||||||
|
<td class="text-center" style="padding:4px;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
|
||||||
|
<input type="number" step="0.01"
|
||||||
|
class="prev-input <?= $ownerTextClass ?> cat-input-<?= $safeOwnerCls ?>-<?= $month ?>"
|
||||||
|
style="width: 70px;"
|
||||||
|
value="<?= $amount != 0 ? round($amount) : '' ?>"
|
||||||
|
placeholder="-"
|
||||||
|
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= htmlspecialchars($currentOwner, ENT_QUOTES) ?>', this)">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<tr class="row-extres">
|
||||||
|
<td class="sticky-col"><strong><?= tr('bud_sav_extra') ?></strong></td>
|
||||||
|
<?php foreach ($months as $month):
|
||||||
|
$total = $data[$month]['TOTAL_BANQUE'] ?? 0;
|
||||||
|
$sum = 0;
|
||||||
|
foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0);
|
||||||
|
$extra = $total - $sum;
|
||||||
|
?>
|
||||||
|
<td class="text-center font-bold sum-target" id="extra_<?= $safeOwnerCls ?>_<?= $month ?>" style="color: <?= $extra >= 0 ? '#10b981' : '#ef4444' ?>; padding:12px;">
|
||||||
|
<?= number_format($extra, 0, ',', ' ') ?> €
|
||||||
|
</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="savingsModal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="max-width: 600px; width: 95%;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 id="savingsModalTitle" class="pf-modal-title" style="margin:0;"><?= tr('bud_sav_modal_title_add') ?></h3>
|
||||||
|
<button type="button" onclick="document.getElementById('savingsModal').style.display='none'; document.body.classList.remove('no-scroll');" style="border:none; background:none; font-size:1.8rem; cursor:pointer; color:#64748b; line-height:1;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="modules/budget/includes/api/save-savings.php" method="POST" id="savingsForm">
|
||||||
|
<input type="hidden" name="owner" id="sav_owner">
|
||||||
|
<input type="hidden" name="redirect_tab" id="redirect_tab" value="<?= htmlspecialchars($requestedOwner) ?>">
|
||||||
|
<input type="hidden" name="month_date" id="sav_date_hidden">
|
||||||
|
|
||||||
|
<div style="display:flex; gap:15px; margin-bottom:20px;">
|
||||||
|
<div class="form-group" style="flex:1; margin:0;">
|
||||||
|
<label class="pf-label"><?= tr('bud_sav_month_concerned') ?></label>
|
||||||
|
<input type="month" id="sav_month" required class="pf-input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="flex:1; margin:0;">
|
||||||
|
<label class="pf-label"><?= tr('bud_sav_total_bank_eur') ?></label>
|
||||||
|
<input type="number" step="0.01" name="values[TOTAL_BANQUE]" id="sav_total" required class="pf-input no-spinners" style="font-weight:bold; color:#2563eb;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="separator" style="margin: 20px 0; border-bottom: 1px solid #e2e8f0;"></div>
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
|
||||||
|
<div>
|
||||||
|
<h4 style="margin:0; font-size:1rem; color:#1e293b;"><?= tr('bud_sav_ventilation') ?></h4>
|
||||||
|
<span style="font-size:0.8rem; color:#64748b;"><?= tr('bud_sav_adj_help') ?></span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pf-btn btn-secondary" onclick="addCustomEpargneLine()" style="padding:4px 10px; height:auto; width:auto; font-size:0.9rem;">+ <?= tr('bud_sav_add_line') ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; padding:0 5px 5px 5px; font-size:0.8rem; color:#64748b; font-weight:600;">
|
||||||
|
<div style="flex:2;"><?= tr('bud_category') ?></div>
|
||||||
|
<div style="width:100px;"><?= tr('bud_sav_current') ?></div>
|
||||||
|
<div style="width:90px;"><?= tr('bud_sav_adjust') ?></div>
|
||||||
|
<div style="width:100px;"><?= tr('bud_sav_new') ?></div>
|
||||||
|
<div style="width:28px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="linesContainer" style="max-height: 350px; overflow-y: auto; padding-right:5px; display:flex; flex-direction:column; gap:10px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:25px; display:flex; justify-content:flex-end; gap:10px;">
|
||||||
|
<button type="button" onclick="document.getElementById('savingsModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary" style="width:auto; margin:0;"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn" style="width:auto; margin:0;"><?= tr('btn_save') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="fabSumMode" class="pf-fab-sum" onclick="toggleSumMode()" title="<?= tr('bud_sav_sum_mode_title') ?>">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path><line x1="8" y1="12" x2="16" y2="12"></line><line x1="12" y1="8" x2="12" y2="16"></line></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="sumResultBar" class="pf-sum-bar">
|
||||||
|
<span class="pf-sum-label"><?= tr('bud_sav_selection') ?></span>
|
||||||
|
<span id="sumResultValue" class="pf-sum-value">0 €</span>
|
||||||
|
<button onclick="toggleSumMode()" class="pf-sum-close" title="<?= tr('btn_close') ?>">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
|
||||||
|
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
|
||||||
|
window.I18N = {
|
||||||
|
...(window.I18N || {}),
|
||||||
|
'bud_sav_modal_title_add': <?= json_encode(tr('bud_sav_modal_title_add')) ?>,
|
||||||
|
'bud_sav_modal_title_edit': <?= json_encode(tr('bud_sav_modal_title_edit')) ?>,
|
||||||
|
'bud_sav_ph_name': <?= json_encode(tr('bud_sav_ph_name')) ?>,
|
||||||
|
'bud_sav_confirm_delete_month': <?= json_encode(tr('bud_sav_confirm_delete_month')) ?>,
|
||||||
|
'bud_sav_prompt_duplicate': <?= json_encode(tr('bud_sav_prompt_duplicate')) ?>,
|
||||||
|
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
|
||||||
|
'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>,
|
||||||
|
'bud_err_network_dup': <?= json_encode(tr('bud_err_network_dup')) ?>,
|
||||||
|
'bud_sav_saving': <?= json_encode(tr('bud_sav_saving')) ?>,
|
||||||
|
'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?>
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sécurisation de la devise pour la calculatrice
|
||||||
|
const systemCurrency = (typeof window.CONFIG !== 'undefined' && window.CONFIG.CURRENCY) ? window.CONFIG.CURRENCY : '€';
|
||||||
|
|
||||||
|
// --- 2. GESTION DE L'ÉDITION INVISIBLE EN DIRECT (CORRIGÉE 🚀) ---
|
||||||
|
const cycleConfigs = <?= json_encode($cycleConfigs ?? []) ?>;
|
||||||
|
|
||||||
|
async function updateEpargneCell(month, category, owner, inputEl) {
|
||||||
|
const val = parseFloat(inputEl.value) || 0;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('action', 'update_single_entry');
|
||||||
|
formData.append('month_date', month);
|
||||||
|
formData.append('category', category);
|
||||||
|
formData.append('owner', owner);
|
||||||
|
formData.append('amount', val);
|
||||||
|
formData.append('ajax', '1'); // 🚀 CRITIQUE : Informe l'API
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🚀 CRITIQUE : pachaFetch + Chemin absolu
|
||||||
|
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error("Erreur de sauvegarde :", result.error);
|
||||||
|
alert(window.I18N['bud_err_tech'] + " : " + (result.error || "Erreur serveur"));
|
||||||
|
return; // On stoppe la mise à jour visuelle si la BDD a refusé
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erreur réseau :", err);
|
||||||
|
alert(window.I18N['bud_err_tech'] || 'Erreur technique');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si on arrive ici, la BDD est bien à jour ! On met à jour le visuel.
|
||||||
|
const safeOwnerClass = owner.replace(/\s+/g, '_');
|
||||||
|
const totalInput = document.querySelector(`.total-input-${CSS.escape(safeOwnerClass)}-${month}`);
|
||||||
|
const totalVal = parseFloat(totalInput ? totalInput.value : 0) || 0;
|
||||||
|
|
||||||
|
let sumCats = 0;
|
||||||
|
document.querySelectorAll(`.cat-input-${CSS.escape(safeOwnerClass)}-${month}`).forEach(inp => {
|
||||||
|
sumCats += parseFloat(inp.value) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const extra = totalVal - sumCats;
|
||||||
|
const extraCell = document.getElementById(`extra_${safeOwnerClass}_${month}`);
|
||||||
|
|
||||||
|
if (extraCell) {
|
||||||
|
extraCell.innerText = Math.round(extra).toLocaleString(window.appLang) + ' €';
|
||||||
|
extraCell.style.color = extra >= 0 ? '#10b981' : '#ef4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isSumModeActive) updateSumResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomEpargneLine(catName = '', amount = '') {
|
||||||
|
const container = document.getElementById('linesContainer');
|
||||||
|
const baseAmount = (amount !== '' && amount !== null) ? parseFloat(amount).toFixed(2) : '0.00';
|
||||||
|
const inputName = catName ? `values[${catName}]` : '';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="ventilation-line" style="display:flex; gap:10px; align-items:center; background:#f8fafc; padding:8px; border-radius:8px; border:1px solid #e2e8f0;">
|
||||||
|
<div style="flex:2;">
|
||||||
|
<input type="text" class="pf-input cat-name-input" value="${catName}" placeholder="${window.I18N['bud_sav_ph_name'] || 'Catégorie'}" oninput="updateCustomFieldName(this)" style="padding:6px; font-size:0.9rem;" required>
|
||||||
|
</div>
|
||||||
|
<div style="width:100px;">
|
||||||
|
<input type="number" step="0.01" class="pf-input base-amount no-spinners" value="${baseAmount}" oninput="recalculateCustomLine(this)" style="padding:6px; font-size:0.9rem; background:#fff;">
|
||||||
|
</div>
|
||||||
|
<div style="width:90px;">
|
||||||
|
<input type="number" step="0.01" class="pf-input adjustment-amount no-spinners" placeholder="+ / -" oninput="recalculateCustomLine(this)" style="padding:6px; font-size:0.9rem; color:#f59e0b; font-weight:bold;">
|
||||||
|
</div>
|
||||||
|
<div style="width:100px;">
|
||||||
|
<input type="number" step="0.01" name="${inputName}" class="pf-input final-amount no-spinners" value="${baseAmount}" style="padding:6px; font-size:0.9rem; font-weight:bold; background:#e0f2fe; border-color:#bae6fd; color:#0369a1;" readonly>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="this.parentElement.remove()" style="width:28px; height:28px; border:none; background:#fee2e2; color:#ef4444; border-radius:4px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-weight:bold;">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCustomFieldName(inputElement) {
|
||||||
|
const line = inputElement.closest('.ventilation-line');
|
||||||
|
const finalInput = line.querySelector('.final-amount');
|
||||||
|
const newName = inputElement.value.trim();
|
||||||
|
if (newName) finalInput.name = `values[${newName}]`;
|
||||||
|
else finalInput.name = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalculateCustomLine(inputElement) {
|
||||||
|
const line = inputElement.closest('.ventilation-line');
|
||||||
|
const baseInput = line.querySelector('.base-amount');
|
||||||
|
const adjInput = line.querySelector('.adjustment-amount');
|
||||||
|
const finalInput = line.querySelector('.final-amount');
|
||||||
|
|
||||||
|
const base = parseFloat(baseInput.value) || 0;
|
||||||
|
const adj = parseFloat(adjInput.value) || 0;
|
||||||
|
|
||||||
|
finalInput.value = (base + adj).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCustomSavingsMonth(monthDate, owner, rowData) {
|
||||||
|
document.getElementById('sav_owner').value = owner;
|
||||||
|
const ym = monthDate.substring(0, 7);
|
||||||
|
document.getElementById('sav_month').value = ym;
|
||||||
|
|
||||||
|
const dateObj = new Date(monthDate);
|
||||||
|
const monthName = dateObj.toLocaleDateString(window.appLang, { month: 'long', year: 'numeric' });
|
||||||
|
document.getElementById('savingsModalTitle').innerText = (window.I18N['bud_sav_modal_title_edit'] || 'Editer') + " " + monthName + " (" + owner + ")";
|
||||||
|
|
||||||
|
document.getElementById('sav_total').value = rowData['TOTAL_BANQUE'] || '';
|
||||||
|
|
||||||
|
const container = document.getElementById('linesContainer');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
for (const [cat, val] of Object.entries(rowData)) {
|
||||||
|
if (cat !== 'TOTAL_BANQUE') addCustomEpargneLine(cat, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container.children.length === 0) addCustomEpargneLine();
|
||||||
|
|
||||||
|
document.getElementById('savingsModal').style.display = 'flex';
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCustomSavingsModal(owner) {
|
||||||
|
document.getElementById('sav_owner').value = owner;
|
||||||
|
document.getElementById('sav_month').value = '';
|
||||||
|
document.getElementById('sav_total').value = '';
|
||||||
|
|
||||||
|
document.getElementById('savingsModalTitle').innerText = (window.I18N['bud_sav_modal_title_add'] || 'Ajouter') + " (" + owner + ")";
|
||||||
|
|
||||||
|
const container = document.getElementById('linesContainer');
|
||||||
|
container.innerHTML = '';
|
||||||
|
addCustomEpargneLine();
|
||||||
|
|
||||||
|
document.getElementById('savingsModal').style.display = 'flex';
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modal = document.getElementById('savingsModal');
|
||||||
|
if (event.target == modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const savingsForm = document.getElementById('savingsForm');
|
||||||
|
if (savingsForm) {
|
||||||
|
savingsForm.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const ym = document.getElementById('sav_month').value;
|
||||||
|
if(ym) {
|
||||||
|
document.getElementById('sav_date_hidden').value = ym + '-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitBtn = this.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitBtn.innerText;
|
||||||
|
submitBtn.innerText = window.I18N['bud_sav_saving'] || 'Sauvegarde...';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
formData.append('ajax', '1'); // 🚀 CRITIQUE
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🚀 CRITIQUE : pachaFetch + Chemin absolu forcé
|
||||||
|
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
window.location.reload(); // Ne recharge QUE si le serveur a dit OK
|
||||||
|
} else {
|
||||||
|
alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (result.error || "Inconnue"));
|
||||||
|
submitBtn.innerText = originalText;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur catch:", error);
|
||||||
|
alert(window.I18N['bud_err_tech'] || 'Erreur Technique');
|
||||||
|
submitBtn.innerText = originalText;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntireMonth(monthDate, owner) {
|
||||||
|
const rawMsg = window.I18N['bud_sav_confirm_delete_month'] || "Supprimer %m pour %o ?";
|
||||||
|
const msg = rawMsg.replace('%m', monthDate).replace('%o', owner);
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "delete_month_global");
|
||||||
|
formData.append("month_date", monthDate);
|
||||||
|
formData.append("owner", owner);
|
||||||
|
formData.append("ajax", "1"); // 🚀 CRITIQUE
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: "POST", body: formData });
|
||||||
|
if (result.success) window.location.reload();
|
||||||
|
else alert(result.error || window.I18N['bud_err_delete']);
|
||||||
|
} catch(err) {
|
||||||
|
alert(window.I18N['bud_err_delete'] || 'Erreur lors de la suppression');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function duplicateLastMonth(lastMonthDate, owner) {
|
||||||
|
let dateObj = new Date(lastMonthDate);
|
||||||
|
dateObj.setMonth(dateObj.getMonth() + 1);
|
||||||
|
let year = dateObj.getFullYear();
|
||||||
|
let month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
|
let nextMonthStr = `${year}-${month}-01`;
|
||||||
|
|
||||||
|
const formatMonth = (d) => {
|
||||||
|
let str = new Date(d).toLocaleDateString(window.appLang, { month: 'long', year: 'numeric' });
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
let defaultTotal = "";
|
||||||
|
if (cycleConfigs[nextMonthStr] && cycleConfigs[nextMonthStr].start_balance !== undefined) {
|
||||||
|
defaultTotal = cycleConfigs[nextMonthStr].start_balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMsg = window.I18N['bud_sav_prompt_duplicate'] || "Dupliquer vers le mois suivant ?";
|
||||||
|
const message = rawMsg.replace('%s', formatMonth(lastMonthDate)).replace(/%t[12]/g, formatMonth(nextMonthStr));
|
||||||
|
|
||||||
|
let newTotal = prompt(message, defaultTotal);
|
||||||
|
|
||||||
|
if (newTotal !== null && newTotal.trim() !== "") {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "duplicate_month");
|
||||||
|
formData.append("source_date", lastMonthDate);
|
||||||
|
formData.append("target_date", nextMonthStr);
|
||||||
|
formData.append("new_total", newTotal);
|
||||||
|
formData.append("owner", owner);
|
||||||
|
formData.append("ajax", "1"); // 🚀 CRITIQUE
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pachaFetch('/modules/budget/includes/api/save-savings.php', { method: "POST", body: formData });
|
||||||
|
if (result.success) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (result.error || "Inconnue"));
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
alert(window.I18N['bud_err_network_dup'] || 'Erreur réseau.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CALCULATRICE ---
|
||||||
|
let isSumModeActive = false;
|
||||||
|
let selectedElementsForSum = new Set();
|
||||||
|
|
||||||
|
function toggleSumMode() {
|
||||||
|
isSumModeActive = !isSumModeActive;
|
||||||
|
const fab = document.getElementById('fabSumMode');
|
||||||
|
const resultBar = document.getElementById('sumResultBar');
|
||||||
|
|
||||||
|
if (isSumModeActive) {
|
||||||
|
fab.classList.add('active');
|
||||||
|
document.body.classList.add('sum-mode-active');
|
||||||
|
resultBar.classList.add('visible');
|
||||||
|
updateSumResult();
|
||||||
|
} else {
|
||||||
|
fab.classList.remove('active');
|
||||||
|
document.body.classList.remove('sum-mode-active');
|
||||||
|
resultBar.classList.remove('visible');
|
||||||
|
|
||||||
|
selectedElementsForSum.forEach(el => el.classList.remove('sum-selected'));
|
||||||
|
selectedElementsForSum.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNumberFromText(text) {
|
||||||
|
if (!text) return 0;
|
||||||
|
const cleanText = text.replace(',', '.').replace(/[^\d.-]/g, '');
|
||||||
|
return parseFloat(cleanText) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSumResult() {
|
||||||
|
let total = 0;
|
||||||
|
selectedElementsForSum.forEach(el => {
|
||||||
|
let val = 0;
|
||||||
|
if (el.tagName === 'INPUT') {
|
||||||
|
val = parseFloat(el.value) || 0;
|
||||||
|
} else {
|
||||||
|
val = extractNumberFromText(el.innerText);
|
||||||
|
}
|
||||||
|
total += val;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sumResultValue').innerText = Math.round(total).toLocaleString(window.appLang) + ' ' + systemCurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!isSumModeActive) return;
|
||||||
|
|
||||||
|
const targetElement = e.target.closest('input[type="number"], .sum-target');
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (selectedElementsForSum.has(targetElement)) {
|
||||||
|
selectedElementsForSum.delete(targetElement);
|
||||||
|
targetElement.classList.remove('sum-selected');
|
||||||
|
} else {
|
||||||
|
selectedElementsForSum.add(targetElement);
|
||||||
|
targetElement.classList.add('sum-selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSumResult();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
// modules/budget/views/provisions.php
|
||||||
|
|
||||||
|
// --- RÉCUPÉRATION DES DÉPENSES PRÉVUES (MULTI-TENANT PDO) ---
|
||||||
|
$stmt = $pdo->query("SELECT id, title, amount, expected_date FROM pf_expected_expenses WHERE is_paid = 0 ORDER BY expected_date ASC");
|
||||||
|
$provisions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// --- RÉCUPÉRATION DES APPORTS ET DETTES (CLEARING) ---
|
||||||
|
$currentYear = date('Y');
|
||||||
|
|
||||||
|
// 1. Apports de base (Eco Family) des parents configurés pour l'année
|
||||||
|
$stmtConfig = $pdo->prepare("SELECT person, eco_family FROM pf_salary_config WHERE year = ?");
|
||||||
|
$stmtConfig->execute([$currentYear]);
|
||||||
|
$configs = $stmtConfig->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
// 2. Dettes non résolues par personne (issues des Avances & Tricount)
|
||||||
|
$stmtDebts = $pdo->query("SELECT payer, SUM(amount) as total_debt FROM pf_advances WHERE is_resolved = 0 GROUP BY payer");
|
||||||
|
$debts = $stmtDebts->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
function formatDate($dateString) {
|
||||||
|
if (!$dateString) return '';
|
||||||
|
return date('d/m/Y', strtotime($dateString));
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="budget-view">
|
||||||
|
<div class="view-header">
|
||||||
|
<h2><?= tr('budget_provisions_title') ?></h2>
|
||||||
|
|
||||||
|
<button class="pf-btn pf-btn-primary" onclick="openOptimizeModal()">
|
||||||
|
✨ <?= tr('btn_optimize_cashflow') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-card">
|
||||||
|
<h3 class="pf-card-h2"><?= tr('add_new_provision') ?></h3>
|
||||||
|
|
||||||
|
<form id="form-add-provision" data-action="/modules/budget/includes/api/add-provision.php">
|
||||||
|
<div class="provisions-form-grid">
|
||||||
|
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label" for="prov-title"><?= tr('provision_label') ?></label>
|
||||||
|
<input type="text" id="prov-title" name="title" class="pf-input" placeholder="<?= tr('provision_placeholder_wood') ?>" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label" for="prov-amount"><?= tr('amount') ?> (€)</label>
|
||||||
|
<input type="number" id="prov-amount" name="amount" class="pf-input no-spinners input-amount-highlight" step="0.01" min="0.01" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label" for="prov-date"><?= tr('expected_date') ?></label>
|
||||||
|
<input type="date" id="prov-date" name="expected_date" class="pf-input" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="pf-btn pf-btn-secondary">
|
||||||
|
<?= tr('btn_add') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="budget-table-card table-responsive">
|
||||||
|
<?php if (empty($provisions)): ?>
|
||||||
|
<div class="optimization-hint" style="padding: 30px;">
|
||||||
|
<p><?= tr('no_provisions') ?></p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<table class="pf-table savings-table provisions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?= tr('provision_label') ?></th>
|
||||||
|
<th style="width: 150px;"><?= tr('amount') ?></th>
|
||||||
|
<th style="width: 150px;"><?= tr('expected_date') ?></th>
|
||||||
|
<th style="width: 100px;"><?= tr('actions') ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($provisions as $p): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="prov-title-cell">
|
||||||
|
<?= htmlspecialchars($p['title']) ?>
|
||||||
|
</td>
|
||||||
|
<td class="prov-amount-cell">
|
||||||
|
<?= number_format($p['amount'], 2, ',', ' ') ?> €
|
||||||
|
</td>
|
||||||
|
<td class="prov-date-cell">
|
||||||
|
<?= formatDate($p['expected_date']) ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-right" style="padding-right: 15px;">
|
||||||
|
<button class="btn-icon-action delete btn-safe-click" title="<?= tr('btn_delete') ?>" onclick="deleteProvision(<?= $p['id'] ?>)">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="optimizeModal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content provisions-modal-content">
|
||||||
|
|
||||||
|
<div class="pf-modal-header">
|
||||||
|
<h3 class="pf-modal-title">🧠 <?= tr('optimization_assistant_title') ?></h3>
|
||||||
|
<button type="button" class="pf-modal-close" onclick="closeOptimizeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="form-optimize" data-action="/modules/budget/includes/api/optimize-cashflow.php">
|
||||||
|
<div id="dynamic-savings-inputs">
|
||||||
|
<?php if (empty($configs)): ?>
|
||||||
|
<p class="text-danger font-bold pf-muted-tiny"><?= tr('budget_opti_no_config') ?></p>
|
||||||
|
<input type="number" step="0.01" min="0" name="savings[Global]" class="pf-input no-spinners" required>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($configs as $person => $ecoBase):
|
||||||
|
$debt = $debts[$person] ?? 0;
|
||||||
|
?>
|
||||||
|
<div class="pf-form-group provision-person-card">
|
||||||
|
<label class="pf-label provision-person-header">
|
||||||
|
<strong class="text-main"><?= htmlspecialchars($person) ?></strong>
|
||||||
|
<span class="pf-muted-note">
|
||||||
|
<?php if ($debt > 0): ?>
|
||||||
|
<span class="text-danger font-bold">(Dette en attente : <?= (float)$debt ?> €)</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="provision-input-group">
|
||||||
|
<input type="number" step="0.01" min="0" name="savings[<?= htmlspecialchars($person) ?>]" class="pf-input no-spinners input-amount-success" value="<?= (float)$ecoBase ?>" required>
|
||||||
|
<span class="currency-symbol">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="pf-btn pf-btn-primary btn-block">
|
||||||
|
<?= tr('calculate') ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="pf-divider">
|
||||||
|
|
||||||
|
<div id="optimization-results">
|
||||||
|
<p class="optimization-hint">
|
||||||
|
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
|
||||||
|
window.I18N = {
|
||||||
|
...(window.I18N || {}),
|
||||||
|
'btn_delete': <?= json_encode(tr('btn_delete')) ?>,
|
||||||
|
'error_generic': <?= json_encode(tr('error_generic')) ?>,
|
||||||
|
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
|
||||||
|
'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 2. GESTION DU FORMULAIRE D'AJOUT ---
|
||||||
|
const formAdd = document.getElementById('form-add-provision');
|
||||||
|
if (formAdd) {
|
||||||
|
formAdd.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const submitBtn = this.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitBtn.innerText;
|
||||||
|
submitBtn.innerText = '⏳';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pachaFetch(this.getAttribute('data-action'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: new FormData(this)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert(result.message || window.I18N['error_generic']);
|
||||||
|
submitBtn.innerText = originalText;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(window.I18N['bud_err_tech']);
|
||||||
|
submitBtn.innerText = originalText;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. GESTION DE LA SUPPRESSION ---
|
||||||
|
async function deleteProvision(id) {
|
||||||
|
if (!confirm(window.I18N['btn_delete'] + ' ?')) return;
|
||||||
|
try {
|
||||||
|
const result = await pachaFetch('/modules/budget/includes/api/delete-provision.php', {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({ id: id })
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert(result.message);
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
alert(window.I18N['bud_err_tech']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. CONTROL DE LA MODALE D'OPTIMISATION ---
|
||||||
|
function openOptimizeModal() {
|
||||||
|
document.getElementById('optimization-results').innerHTML = `
|
||||||
|
<p class="optimization-hint">
|
||||||
|
Vérifiez vos apports théoriques ci-dessus pour calculer la stratégie de répartition (Clearing automatique si aucune dépense).
|
||||||
|
</p>`;
|
||||||
|
document.getElementById('optimizeModal').classList.add('open');
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOptimizeModal() {
|
||||||
|
document.getElementById('optimizeModal').classList.remove('open');
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modal = document.getElementById('optimizeModal');
|
||||||
|
if (event.target == modal) {
|
||||||
|
closeOptimizeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. EXECUTION ET SUBMIT DE L'ALGORITHME (formOptimize) ---
|
||||||
|
const formOptimize = document.getElementById('form-optimize');
|
||||||
|
if (formOptimize) {
|
||||||
|
formOptimize.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const resultsContainer = document.getElementById('optimization-results');
|
||||||
|
const submitBtn = this.querySelector('button[type="submit"]');
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
resultsContainer.innerHTML = '<div class="optimization-hint">⏳ Analyse des quinzaines bancaires...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pachaFetch(this.getAttribute('data-action'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: new FormData(this)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
resultsContainer.innerHTML = result.html;
|
||||||
|
} else {
|
||||||
|
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${result.message}</p>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur d'optimisation :", error);
|
||||||
|
resultsContainer.innerHTML = `<p class="text-danger font-bold text-center">${window.I18N['bud_err_tech']}</p>`;
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
<?php
|
||||||
|
// modules/budget/views/recap.php
|
||||||
|
|
||||||
|
// 1. Récupération des Catégories dynamiques
|
||||||
|
$stmtCats = $pdo->query("SELECT code, label, icon, type FROM pf_budget_categories ORDER BY type DESC, label ASC");
|
||||||
|
$dbCategories = $stmtCats->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// 2. Récupération des Salaires/Mensualités configurés (Revenus automatiques)
|
||||||
|
$currentYear = isset($_GET['y']) ? (int)$_GET['y'] : date('Y');
|
||||||
|
$stmtSalaries = $pdo->prepare("SELECT id, person, mensualite FROM pf_salary_config WHERE year = ?");
|
||||||
|
$stmtSalaries->execute([$currentYear]);
|
||||||
|
$salaries = $stmtSalaries->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// 3. Récupération des Items du Budget (Charges Fixes et Estimations)
|
||||||
|
$stmt = $pdo->query("SELECT * FROM pf_budget_items WHERE category != 'SAVINGS' ORDER BY is_estimate ASC, sort_order ASC, name ASC");
|
||||||
|
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// 4. Gestion du mois actif
|
||||||
|
$stmtActive = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'active_gestion_month' LIMIT 1");
|
||||||
|
$defaultActiveMonth = $stmtActive->fetchColumn() ?: date('Y-m-01');
|
||||||
|
$currentMonth = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m', strtotime($defaultActiveMonth));
|
||||||
|
$viewMonthDate = "$currentYear-$currentMonth-01";
|
||||||
|
|
||||||
|
// 5. Récupération optimisée et unifiée du Réel
|
||||||
|
// On récupère tout en une seule requête pour éviter les décalages entre ID et Catégorie
|
||||||
|
$stmtReal = $pdo->prepare("
|
||||||
|
SELECT budget_item_id, category, SUM(amount) as total_real
|
||||||
|
FROM pf_expenses
|
||||||
|
WHERE gestion_month = ?
|
||||||
|
GROUP BY budget_item_id, category
|
||||||
|
");
|
||||||
|
$stmtReal->execute([$viewMonthDate]);
|
||||||
|
$allExpenses = $stmtReal->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// On initialise les tableaux de correspondance
|
||||||
|
$realTotalsById = []; // Pour les lignes liées par ID
|
||||||
|
$realTotalsByCat = []; // Pour les lignes orphelines (NULL) liées par Catégorie
|
||||||
|
|
||||||
|
foreach ($allExpenses as $row) {
|
||||||
|
if (!empty($row['budget_item_id'])) {
|
||||||
|
$realTotalsById[$row['budget_item_id']] = (float)$row['total_real'];
|
||||||
|
} else {
|
||||||
|
$realTotalsByCat[$row['category']] = (float)$row['total_real'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmtSalariesReal = $pdo->prepare("SELECT salary_id, SUM(amount) as total_salary FROM pf_expenses WHERE gestion_month = ? AND salary_id IS NOT NULL GROUP BY salary_id");
|
||||||
|
$stmtSalariesReal->execute([$viewMonthDate]);
|
||||||
|
$realTotalsBySalary = $stmtSalariesReal->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
// Récupération des détails pour le mapping par mots-clés (si besoin de précision)
|
||||||
|
$stmtLabels = $pdo->prepare("SELECT label, amount, category FROM pf_expenses WHERE gestion_month = ? AND budget_item_id IS NULL");
|
||||||
|
$stmtLabels->execute([$viewMonthDate]);
|
||||||
|
$unlinkedExpenses = $stmtLabels->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Gestion des libellés et totaux
|
||||||
|
$currentMonthName = tr('month_' . str_pad((int)$currentMonth, 2, '0', STR_PAD_LEFT)) . ' ' . $currentYear;
|
||||||
|
$totalDepenses = 0;
|
||||||
|
$totalRevenus = 0;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="budget-view">
|
||||||
|
<div class="view-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h2 style="margin:0;"><?= tr('bud_recap_monthly_title') ?></h2>
|
||||||
|
<button onclick="openRecapModal('add')" class="pf-btn">+ <?= tr('bud_recap_btn_add') ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive" style="background:white; border-radius:16px; box-shadow:var(--shadow-sm); border:1px solid #e2e8f0; overflow:hidden;">
|
||||||
|
<table class="pf-table" style="margin:0; box-shadow:none;">
|
||||||
|
<thead style="background:#f8fafc;">
|
||||||
|
<tr>
|
||||||
|
<th><?= tr('bud_label_name') ?></th>
|
||||||
|
<th><?= tr('bud_planned_amount') ?></th>
|
||||||
|
<th><?= tr('bud_type') ?></th>
|
||||||
|
<th><?= tr('bud_day') ?></th>
|
||||||
|
<th><?= tr('bud_month_state') ?> (<?= $currentMonthName ?>)</th>
|
||||||
|
<th style="text-align:right;"><?= tr('actions') ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($salaries as $salary):
|
||||||
|
$mensualite = (float)$salary['mensualite'];
|
||||||
|
$totalRevenus += $mensualite;
|
||||||
|
|
||||||
|
// Calcul du réel perçu
|
||||||
|
$realPerçu = isset($realTotalsBySalary[$salary['id']]) ? (float)$realTotalsBySalary[$salary['id']] : 0;
|
||||||
|
$gap = $realPerçu - $mensualite;
|
||||||
|
$isAutoChecked = ($realPerçu >= ($mensualite - 0.10));
|
||||||
|
?>
|
||||||
|
<tr class="row-income" style="border-bottom:1px solid var(--border-light); background:#f0fdf4;">
|
||||||
|
<td style="padding:15px;">
|
||||||
|
<strong>Apport <?= htmlspecialchars($salary['person']) ?></strong>
|
||||||
|
<span title="Le montant cible est défini dans les paramètres" style="font-size:0.8rem; cursor:help;">⚙️</span>
|
||||||
|
</td>
|
||||||
|
<td class="cell-amount" style="font-weight:600; padding:15px; color:#10b981;">
|
||||||
|
+ <?= number_format($mensualite, 2, ',', ' ') ?> €
|
||||||
|
|
||||||
|
<?php if ($realPerçu > 0): ?>
|
||||||
|
<?php if ($gap > 0.05): ?>
|
||||||
|
<div style="font-size:0.75rem; color:#10b981; font-weight:bold;">Bonus : +<?= number_format($gap, 2, ',', ' ') ?> €</div>
|
||||||
|
<?php elseif ($gap < -0.05): ?>
|
||||||
|
<div style="font-size:0.75rem; color:#f59e0b;">Manque : <?= number_format(abs($gap), 2, ',', ' ') ?> €</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="font-size:0.75rem; color:#10b981;">Atteint ✓</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="padding:15px;">
|
||||||
|
<span class="badge-type" style="background:#dcfce7; color:#16a34a; padding:4px 8px; border-radius:12px; font-size:0.8rem; font-weight:600;">Revenu Mensuel</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:15px; color:#64748b; font-weight:bold;">-</td>
|
||||||
|
<td style="padding:15px;">
|
||||||
|
<?php if ($isAutoChecked): ?>
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:5px; background:#dcfce7; color:#16a34a; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #bbf7d0;"><span>✓</span> Reçu</div>
|
||||||
|
<?php elseif ($realPerçu > 0): ?>
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:5px; background:#fffbeb; color:#d97706; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #fde68a;"><span>⏳</span> Partiel</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:5px; background:#f8fafc; color:#94a3b8; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #e2e8f0;"><span>○</span> En attente</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="padding:15px; text-align:right;">
|
||||||
|
<small style="color:#94a3b8;">Via Paramètres</small>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
foreach ($items as $item):
|
||||||
|
// 1. Calculs de base
|
||||||
|
$targetAbs = abs((float)$item['amount']);
|
||||||
|
$amountToAdd = ($item['type'] === 'Annuel') ? $targetAbs / 12 : $targetAbs;
|
||||||
|
$totalDepenses += $amountToAdd;
|
||||||
|
|
||||||
|
// 2. RÉINITIALISATION STRICTE
|
||||||
|
$realSum = 0;
|
||||||
|
$hasMatchingExpense = false;
|
||||||
|
|
||||||
|
// A. Correspondance par ID direct (Le lien dur)
|
||||||
|
if (isset($realTotalsById[$item['id']])) {
|
||||||
|
$realSum = (float)$realTotalsById[$item['id']];
|
||||||
|
$hasMatchingExpense = true;
|
||||||
|
}
|
||||||
|
// B. Correspondance par mots-clés bancaires (Le lien intelligent pour les orphelines)
|
||||||
|
elseif (!empty($item['mapping_keywords'])) {
|
||||||
|
$keywords = array_map('trim', explode(',', $item['mapping_keywords']));
|
||||||
|
foreach ($unlinkedExpenses as $uexp) {
|
||||||
|
foreach ($keywords as $kw) {
|
||||||
|
if (!empty($kw) && stripos($uexp['label'], $kw) !== false) {
|
||||||
|
$realSum += (float)$uexp['amount'];
|
||||||
|
$hasMatchingExpense = true;
|
||||||
|
break; // On a trouvé un mot clé sur cette dépense, on passe à l'orpheline suivante
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C. Absorption des orphelines par catégories (Le lien Macro/Micro)
|
||||||
|
if (!empty($item['category'])) {
|
||||||
|
$catCodes = array_map('trim', explode(',', $item['category']));
|
||||||
|
foreach ($catCodes as $cCode) {
|
||||||
|
// Si la catégorie existe dans les orphelines et n'a pas encore été consommée
|
||||||
|
if (!empty($cCode) && isset($realTotalsByCat[$cCode]) && $realTotalsByCat[$cCode] != 0) {
|
||||||
|
$realSum += $realTotalsByCat[$cCode];
|
||||||
|
$hasMatchingExpense = true;
|
||||||
|
// On la vide pour ne pas la compter 2 fois si une autre ligne utilise la même catégorie
|
||||||
|
$realTotalsByCat[$cCode] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Finalisation des variables d'affichage
|
||||||
|
$realAbs = abs($realSum);
|
||||||
|
$isAutoChecked = ($hasMatchingExpense && ($realAbs >= ($targetAbs - 0.10)));
|
||||||
|
$rowClass = 'row-expense' . ($item['is_estimate'] ? ' row-estimate' : '');
|
||||||
|
?>
|
||||||
|
<tr class="<?= $rowClass ?>" style="border-bottom:1px solid var(--border-light);">
|
||||||
|
<td style="padding:15px;">
|
||||||
|
<strong><?= htmlspecialchars($item['name']) ?></strong>
|
||||||
|
<?= $item['is_estimate'] ? ' <small style="color:#64748b;">(Variable)</small>' : '' ?>
|
||||||
|
<?php if(!empty($item['mapping_keywords'])): ?>
|
||||||
|
<span title="Reconnaissance bancaire : <?= htmlspecialchars($item['mapping_keywords']) ?>" style="font-size:0.7rem; cursor:help;">🔗</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="cell-amount" style="font-weight:600; padding:15px; color:#1e293b;">
|
||||||
|
- <?= number_format($targetAbs, 2, ',', ' ') ?> €
|
||||||
|
|
||||||
|
<?php if ($hasMatchingExpense): ?>
|
||||||
|
<?php $gap = $realAbs - $targetAbs;
|
||||||
|
if ($gap > 0.05): ?>
|
||||||
|
<div style="font-size:0.75rem; color:#ef4444; font-weight:bold;">Dépassé : +<?= number_format($gap, 2, ',', ' ') ?> €</div>
|
||||||
|
<?php elseif ($gap < -0.05): ?>
|
||||||
|
<div style="font-size:0.75rem; color:#f59e0b;">Reste : <?= number_format(abs($gap), 2, ',', ' ') ?> €</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="font-size:0.75rem; color:#10b981;">Atteint ✓</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php elseif ($item['type'] === 'Annuel'): ?>
|
||||||
|
<div style="font-size:0.75rem; color:#94a3b8;"><?= number_format($amountToAdd, 2, ',', ' ') ?>/mois</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding:15px;">
|
||||||
|
<span class="badge-type" style="background:#e2e8f0; padding:4px 8px; border-radius:12px; font-size:0.8rem; font-weight:600; color:#475569;">
|
||||||
|
<?= htmlspecialchars($item['type']) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:15px; color:#64748b; font-weight:bold;"><?= $item['payment_day'] ?: '-' ?></td>
|
||||||
|
|
||||||
|
<td style="padding:15px;">
|
||||||
|
<?php if ($isAutoChecked): ?>
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:5px; background:#f0fdf4; color:#16a34a; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #bbf7d0;"><span>✓</span> Validé</div>
|
||||||
|
<?php elseif($hasMatchingExpense): ?>
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:5px; background:#fffbeb; color:#d97706; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #fde68a;"><span>⏳</span> Partiel</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:5px; background:#f8fafc; color:#94a3b8; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #e2e8f0;"><span>○</span> En attente</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding:15px; text-align:right;">
|
||||||
|
<div class="action-buttons" style="display:flex; gap:5px; justify-content:flex-end;">
|
||||||
|
<button class="btn-icon-action edit" onclick='editRecapItem(<?= htmlspecialchars(json_encode($item), ENT_QUOTES, 'UTF-8') ?>)' title="Modifier">✏️</button>
|
||||||
|
<button class="btn-icon-action delete" onclick="deleteRecapItem(<?= $item['id'] ?>)" title="Supprimer">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
<tfoot style="background:#f8fafc;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="1" style="padding:15px;"><strong>Total Revenus Lissés</strong></td>
|
||||||
|
<td colspan="5" style="padding:15px; color:#10b981;"><strong>+ <?= number_format($totalRevenus, 2, ',', ' ') ?> €</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="1" style="padding:15px;"><strong>Total Dépenses & Estimations</strong></td>
|
||||||
|
<td colspan="5" style="padding:15px; color:#ef4444;"><strong>- <?= number_format($totalDepenses, 2, ',', ' ') ?> €</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-top: 2px solid #e2e8f0; background: white;">
|
||||||
|
<td colspan="1" style="padding:15px; font-size:1.1rem;"><strong>Reste à Vivre (Équilibre)</strong></td>
|
||||||
|
<?php $balance = $totalRevenus - $totalDepenses; ?>
|
||||||
|
<td colspan="5" style="padding:15px; font-size: 1.3em;">
|
||||||
|
<strong style="color:<?= $balance >= 0 ? '#10b981' : '#ef4444' ?>;"><?= number_format($balance, 2, ',', ' ') ?> € / mois</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="budgetRecapModal" class="pf-modal" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(15, 23, 42, 0.6); backdrop-filter:blur(4px); align-items:center; justify-content:center;">
|
||||||
|
<div class="pf-modal-content" style="background:white; width:95%; max-width:500px; border-radius:20px; box-shadow:0 20px 25px -5px rgba(0,0,0,0.1); padding:30px; position:relative;">
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 id="recapModalTitle" style="margin:0; font-size:1.2rem; color:#1e293b;">Ajouter une ligne</h3>
|
||||||
|
<button type="button" onclick="closeRecapModal()" style="border:none; background:none; font-size:1.8rem; cursor:pointer; color:#64748b; line-height:1;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="modules/budget/includes/api/manage-item.php" method="POST" id="recapForm">
|
||||||
|
<input type="hidden" name="action" value="save">
|
||||||
|
<input type="hidden" name="id" id="item_id">
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Nom de la ligne</label>
|
||||||
|
<input type="text" name="name" id="item_name" required class="pf-input" placeholder="ex: Assurance Auto, Estimation Courses..." style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:15px; margin-bottom:15px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Montant Prévu (€)</label>
|
||||||
|
<input type="number" step="0.01" name="amount" id="item_amount" required class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Type de Dépense</label>
|
||||||
|
<select name="is_estimate" id="item_is_estimate" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
|
||||||
|
<option value="0">Fixe (Facture, Prélèvement)</option>
|
||||||
|
<option value="1">Variable (Estimation, Enveloppe)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:15px; margin-bottom:15px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">
|
||||||
|
Catégorie(s) Cible(s) <small style="font-weight:normal;">(Maintenez Ctrl/Cmd pour choix multiple)</small>
|
||||||
|
</label>
|
||||||
|
<select name="category_multi[]" id="item_category" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white; min-height: 120px;" multiple required>
|
||||||
|
<?php foreach ($dbCategories as $c): ?>
|
||||||
|
<?php if(strtolower($c['type']) === 'expense'): ?>
|
||||||
|
<option value="<?= htmlspecialchars($c['code']) ?>">
|
||||||
|
<?= htmlspecialchars($c['icon'] . ' ' . $c['label']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Fréquence</label>
|
||||||
|
<select name="type" id="item_type" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
|
||||||
|
<option value="Mensuel">Mensuel</option>
|
||||||
|
<option value="Annuel">Annuel</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;">Mots-clés (Reconnaissance Bancaire)</label>
|
||||||
|
<input type="text" name="mapping_keywords" id="item_keywords" class="pf-input" placeholder="ex: NETFLIX, DOMOFINANCE..." style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:#f0f9ff; border-color:#bae6fd;">
|
||||||
|
<small style="color:#64748b; font-size:0.75rem;">Optionnel. Permet au système de lier automatiquement un import CSV à cette ligne.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top: 25px;">
|
||||||
|
<button type="button" onclick="closeRecapModal()" class="pf-btn btn-secondary" style="width:auto; margin:0; background:#f1f5f9; color:#475569; border:none; padding:10px 20px; border-radius:8px; font-weight:600; cursor:pointer;">Annuler</button>
|
||||||
|
<button type="submit" class="pf-btn" style="width:auto; margin:0; background:#2563eb; color:white; border:none; padding:10px 20px; border-radius:8px; font-weight:600; cursor:pointer;">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openRecapModal(mode) {
|
||||||
|
if (mode === "add") {
|
||||||
|
document.getElementById("recapModalTitle").innerText = 'Ajouter une ligne';
|
||||||
|
document.getElementById("item_id").value = "";
|
||||||
|
document.getElementById("recapForm").reset();
|
||||||
|
}
|
||||||
|
document.getElementById("budgetRecapModal").style.display = "flex";
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRecapModal() {
|
||||||
|
document.getElementById("budgetRecapModal").style.display = "none";
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onclick = function(event) {
|
||||||
|
if (event.target == document.getElementById('budgetRecapModal')) closeRecapModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRecapItem(item) {
|
||||||
|
const data = typeof item === "string" ? JSON.parse(item) : item;
|
||||||
|
document.getElementById("recapModalTitle").innerText = "Editer : " + data.name;
|
||||||
|
document.getElementById("item_id").value = data.id;
|
||||||
|
document.getElementById("item_name").value = data.name;
|
||||||
|
document.getElementById("item_keywords").value = data.mapping_keywords || '';
|
||||||
|
document.getElementById("item_amount").value = Math.abs(data.amount);
|
||||||
|
const selectedCats = data.category ? data.category.split(',') : [];
|
||||||
|
const selectEl = document.getElementById("item_category");
|
||||||
|
Array.from(selectEl.options).forEach(opt => {
|
||||||
|
opt.selected = selectedCats.includes(opt.value);
|
||||||
|
});
|
||||||
|
document.getElementById("item_type").value = data.type;
|
||||||
|
document.getElementById("item_is_estimate").value = data.is_estimate;
|
||||||
|
|
||||||
|
document.getElementById("budgetRecapModal").style.display = "flex";
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('recapForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = this.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true; btn.innerText = '⏳ ...';
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
const cats = formData.getAll('category_multi[]');
|
||||||
|
formData.delete('category_multi[]');
|
||||||
|
formData.append('category', cats.join(','));
|
||||||
|
|
||||||
|
formData.append('ajax', '1');
|
||||||
|
const url = this.getAttribute('action');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.startsWith('/') ? url.substring(1) : url, { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) window.location.reload();
|
||||||
|
else alert("Erreur : " + data.error);
|
||||||
|
} catch (err) {
|
||||||
|
alert("Erreur technique lors de la sauvegarde.");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.innerText = 'Enregistrer';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteRecapItem(id) {
|
||||||
|
if (!confirm("Confirmer la suppression ?")) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "delete"); formData.append("id", id); formData.append("ajax", "1");
|
||||||
|
try {
|
||||||
|
const res = await fetch("modules/budget/includes/api/manage-item.php", { method: "POST", body: formData });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success !== false) window.location.reload();
|
||||||
|
else alert("Erreur : " + data.error);
|
||||||
|
} catch (err) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
<?php
|
||||||
|
// Sécurité : Ce fichier ne doit pas être appelé directement
|
||||||
|
if (!defined('CURRENCY')) {
|
||||||
|
define('CURRENCY', '€');
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="budget-settings-backdrop" id="modal-budget-settings">
|
||||||
|
<div class="budget-settings-modal">
|
||||||
|
<div class="bs-header">
|
||||||
|
<h3>⚙️ <?= tr('budget_settings_title') ?></h3>
|
||||||
|
<button class="bs-close" onclick="closeBudgetSettings()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="bs-layout">
|
||||||
|
<div class="bs-sidebar">
|
||||||
|
<button class="bs-tab-btn active" onclick="switchBsTab('accounts', this)">
|
||||||
|
<?= tr('bs_tab_accounts') ?>
|
||||||
|
<?php if (!$hasAccounts): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
|
||||||
|
</button>
|
||||||
|
<button class="bs-tab-btn" onclick="switchBsTab('categories', this)">
|
||||||
|
<?= tr('bs_tab_categories') ?>
|
||||||
|
<?php if (!$hasCategories): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
|
||||||
|
</button>
|
||||||
|
<button class="bs-tab-btn" onclick="switchBsTab('rules', this)"><?= tr('bs_tab_rules') ?></button>
|
||||||
|
<button class="bs-tab-btn" onclick="switchBsTab('salaries', this)">
|
||||||
|
<?= tr('bs_tab_salaries') ?>
|
||||||
|
<?php if (!$hasSalaries): ?><span class="alert-dot alert-dot-inline">!</span><?php endif; ?>
|
||||||
|
</button>
|
||||||
|
<button class="bs-tab-btn" onclick="switchBsTab('csv', this)"><?= tr('bs_tab_csv') ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bs-content">
|
||||||
|
|
||||||
|
<?php if (!isset($isBudgetSetupOk) || !$isBudgetSetupOk || !$hasBudgetItems): ?>
|
||||||
|
<div class="bs-onboarding-card">
|
||||||
|
<h4 class="bs-onboarding-title">
|
||||||
|
<span>🚀</span> <?= tr('bs_onb_title') ?>
|
||||||
|
</h4>
|
||||||
|
<p class="bs-onboarding-desc"><?= tr('bs_onb_desc') ?></p>
|
||||||
|
<ul class="bs-onboarding-list">
|
||||||
|
<li>
|
||||||
|
<strong class="<?= $hasCategories ? 'text-success' : 'text-danger' ?>"><?= $hasCategories ? '✅' : '❌' ?></strong>
|
||||||
|
<a class="bs-onboarding-link" onclick="switchBsTab('categories', document.querySelectorAll('.bs-tab-btn')[1]); return false;"><?= tr('bs_onb_cat_link') ?></a> : <?= tr('bs_onb_cat_desc') ?>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong class="<?= $hasAccounts ? 'text-success' : 'text-danger' ?>"><?= $hasAccounts ? '✅' : '❌' ?></strong>
|
||||||
|
<a class="bs-onboarding-link" onclick="switchBsTab('accounts', document.querySelectorAll('.bs-tab-btn')[0]); return false;"><?= tr('bs_onb_acc_link') ?></a> : <?= tr('bs_onb_acc_desc') ?>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong class="<?= $hasSalaries ? 'text-success' : 'text-danger' ?>"><?= $hasSalaries ? '✅' : '❌' ?></strong>
|
||||||
|
<a class="bs-onboarding-link" onclick="switchBsTab('salaries', document.querySelectorAll('.bs-tab-btn')[3]); return false;"><?= tr('bs_onb_sal_link') ?></a> : <?= tr('bs_onb_sal_desc') ?>
|
||||||
|
</li>
|
||||||
|
<li style="margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(180, 83, 9, 0.3);">
|
||||||
|
<strong class="<?= $hasBudgetItems ? 'text-success' : 'text-warning' ?>" style="color: <?= $hasBudgetItems ? '' : '#f59e0b' ?>"><?= $hasBudgetItems ? '✅' : '⏳' ?></strong>
|
||||||
|
<a href="?tab=recap" onclick="closeBudgetSettings();" class="bs-onboarding-link"><?= tr('bs_onb_items_link') ?></a> : <?= tr('bs_onb_items_desc') ?>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div id="pane-accounts" class="bs-pane active">
|
||||||
|
<div class="bs-pane-header">
|
||||||
|
<h4 class="bs-section-title"><?= tr('bs_tab_accounts') ?></h4>
|
||||||
|
<div class="bs-currency-selector">
|
||||||
|
<span><?= tr('bs_currency_label') ?> :</span>
|
||||||
|
<select id="bs-currency-select" class="pf-input bs-currency-select" onchange="updateBudgetCurrency(this)">
|
||||||
|
<option value="€">EUR (€)</option>
|
||||||
|
<option value="$">USD ($)</option>
|
||||||
|
<option value="£">GBP (£)</option>
|
||||||
|
<option value="CHF">CHF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bs-list-accounts" class="bs-list-container">⏳ <?= tr('loading') ?></div>
|
||||||
|
<hr class="bs-divider">
|
||||||
|
<h5 class="bs-section-subtitle"><?= tr('bs_add_account_title') ?></h5>
|
||||||
|
<form id="form-add-account" class="bs-form-inline">
|
||||||
|
<input type="text" name="name" class="pf-input bs-input-flex" placeholder="<?= tr('bs_account_name_ph') ?>" required>
|
||||||
|
<select name="type" class="pf-input bs-input-fixed">
|
||||||
|
<option value="checking"><?= tr('bs_type_checking') ?></option>
|
||||||
|
<option value="savings"><?= tr('bs_type_savings') ?></option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pane-categories" class="bs-pane">
|
||||||
|
<h4 class="bs-section-title"><?= tr('bs_tab_categories') ?></h4>
|
||||||
|
<div id="bs-list-categories" class="bs-list-container">⏳ <?= tr('loading') ?></div>
|
||||||
|
<hr class="bs-divider">
|
||||||
|
<h5 class="bs-section-subtitle"><?= tr('bs_add_category_title') ?></h5>
|
||||||
|
<form id="form-add-category" class="bs-form-inline align-center">
|
||||||
|
<input type="text" name="code" class="pf-input bs-input-fixed" placeholder="<?= tr('bs_cat_code_ph') ?>" required>
|
||||||
|
<input type="text" name="label" class="pf-input bs-input-flex" placeholder="<?= tr('bs_cat_label_ph') ?>" required>
|
||||||
|
|
||||||
|
<select name="type" class="pf-input bs-input-fixed">
|
||||||
|
<option value="Expense"><?= tr('bs_type_expense') ?></option>
|
||||||
|
<option value="Income"><?= tr('bs_type_income') ?></option>
|
||||||
|
<option value="Savings"><?= tr('bs_type_savings_cat') ?></option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select name="icon" class="pf-input bs-input-icon">
|
||||||
|
<option value="📌">📌</option><option value="🛒">🛒</option><option value="🥖">🥖</option>
|
||||||
|
<option value="🍽️">🍽️</option><option value="⛽">⛽</option><option value="🚗">🚗</option>
|
||||||
|
<option value="🚆">🚆</option><option value="🏠">🏠</option><option value="⚡">⚡</option>
|
||||||
|
<option value="💧">💧</option><option value="⚕️">⚕️</option><option value="🎒">🎒</option>
|
||||||
|
<option value="👕">👕</option><option value="📱">📱</option><option value="🎮">🎮</option>
|
||||||
|
<option value="✈️">✈️</option><option value="🎁">🎁</option><option value="🐶">🐶</option>
|
||||||
|
<option value="💵">💵</option><option value="🐷">🐷</option><option value="📈">📈</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input type="color" name="color" value="#3b82f6" class="bs-input-color">
|
||||||
|
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pane-rules" class="bs-pane">
|
||||||
|
<h4 class="bs-section-title"><?= tr('bs_tab_rules') ?></h4>
|
||||||
|
<input type="text" id="input-search-rules" class="bs-rule-search" placeholder="<?= tr('bs_rule_search_ph') ?>" onkeyup="filterRules()">
|
||||||
|
<div id="bs-list-rules" class="bs-list-container">⏳ <?= tr('loading') ?></div>
|
||||||
|
<hr class="bs-divider">
|
||||||
|
<h5 class="bs-section-subtitle"><?= tr('bs_add_rule_title') ?></h5>
|
||||||
|
<form id="form-add-rule" class="bs-form-inline align-center">
|
||||||
|
<input type="text" name="keyword" class="pf-input bs-input-flex" placeholder="<?= tr('bs_rule_keyword_ph') ?>" required>
|
||||||
|
<select name="category" id="select-rule-category" class="pf-input bs-input-fixed" required></select>
|
||||||
|
<button type="submit" class="btn btn-secondary"><?= tr('btn_add') ?></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pane-salaries" class="bs-pane">
|
||||||
|
<h4 class="bs-section-title"><?= tr('bs_tab_salaries') ?> (<?= date('Y') ?>)</h4>
|
||||||
|
<div id="bs-list-salaries" class="bs-list-container">⏳ <?= tr('loading') ?></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pane-csv" class="bs-pane">
|
||||||
|
<h4 class="bs-section-title"><?= tr('bs_csv_title') ?></h4>
|
||||||
|
<p class="pf-muted-tiny bs-desc"><?= tr('bs_csv_desc') ?></p>
|
||||||
|
|
||||||
|
<div id="csv-drop-zone" class="bs-csv-dropzone">
|
||||||
|
<div class="bs-csv-icon">📥</div>
|
||||||
|
<h5 class="bs-csv-title"><?= tr('bs_csv_drop_title') ?></h5>
|
||||||
|
<p class="pf-muted-tiny bs-csv-subtitle"><?= tr('bs_csv_drop_desc') ?></p>
|
||||||
|
<input type="file" id="csv_file_input" accept=".csv" style="display: none;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="csv-preview-container" class="bs-csv-preview"></div>
|
||||||
|
|
||||||
|
<h5 class="bs-section-subtitle bordered"><?= tr('bs_csv_settings_title') ?></h5>
|
||||||
|
<form id="form-csv-mapping" onsubmit="saveCsvMapping(event)">
|
||||||
|
<div class="bs-grid-2">
|
||||||
|
<div>
|
||||||
|
<label class="pf-label"><?= tr('bs_csv_delimiter') ?></label>
|
||||||
|
<select name="csv_delimiter" id="csv_delimiter" class="pf-input">
|
||||||
|
<option value=";"><?= tr('bs_csv_delim_semi') ?></option>
|
||||||
|
<option value=","><?= tr('bs_csv_delim_comma') ?></option>
|
||||||
|
<option value="\t"><?= tr('bs_csv_delim_tab') ?></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="pf-label"><?= tr('bs_csv_date_format') ?></label>
|
||||||
|
<select name="csv_date_format" id="csv_date_format" class="pf-input">
|
||||||
|
<option value="d/m/Y">JJ/MM/AAAA</option>
|
||||||
|
<option value="Y-m-d">AAAA-MM-JJ</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 class="bs-section-subtitle">
|
||||||
|
<?= tr('bs_csv_col_index') ?>
|
||||||
|
<small class="bs-text-muted-normal"><?= tr('bs_csv_col_index_help') ?></small>
|
||||||
|
</h5>
|
||||||
|
<div class="bs-grid-2">
|
||||||
|
<div>
|
||||||
|
<label class="pf-label"><?= tr('bs_csv_col_date') ?></label>
|
||||||
|
<input type="number" min="0" name="csv_col_date" id="csv_col_date" class="pf-input" value="0" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="pf-label"><?= tr('bs_csv_col_label') ?></label>
|
||||||
|
<input type="number" min="0" name="csv_col_label" id="csv_col_label" class="pf-input" value="1" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bs-card-gray">
|
||||||
|
<label class="pf-label"><?= tr('bs_csv_amount_mgmt') ?></label>
|
||||||
|
<select name="csv_amount_type" id="csv_amount_type" class="pf-input bs-input-mb" onchange="toggleCsvAmountCols(this.value)">
|
||||||
|
<option value="single"><?= tr('bs_csv_amount_single') ?></option>
|
||||||
|
<option value="split"><?= tr('bs_csv_amount_split') ?></option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="bs-grid-2">
|
||||||
|
<div>
|
||||||
|
<label class="pf-label" id="lbl_col_debit"><?= tr('bs_csv_col_amount') ?></label>
|
||||||
|
<input type="number" min="0" name="csv_col_debit" id="csv_col_debit" class="pf-input" value="8" required>
|
||||||
|
</div>
|
||||||
|
<div id="wrapper_col_credit" style="display:none;">
|
||||||
|
<label class="pf-label"><?= tr('bs_csv_col_credit') ?></label>
|
||||||
|
<input type="number" min="0" name="csv_col_credit" id="csv_col_credit" class="pf-input" value="9">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bs-input-mb">
|
||||||
|
<label class="pf-label"><?= tr('bs_csv_col_ref') ?></label>
|
||||||
|
<input type="number" min="0" name="csv_col_ref" id="csv_col_ref" class="pf-input" value="3">
|
||||||
|
</div>
|
||||||
|
<div class="bs-text-right">
|
||||||
|
<button type="submit" class="btn btn-secondary">💾 <?= tr('btn_save_format') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const tabsContainer = document.querySelector('.budget-tabs-container');
|
||||||
|
const activeTab = document.querySelector('.tab-item.active');
|
||||||
|
if (tabsContainer && activeTab) {
|
||||||
|
if (tabsContainer.scrollWidth > tabsContainer.clientWidth) {
|
||||||
|
const scrollPos = activeTab.offsetLeft - (tabsContainer.offsetWidth / 2) + (activeTab.offsetWidth / 2);
|
||||||
|
tabsContainer.scrollTo({ left: scrollPos, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openBudgetSettings() {
|
||||||
|
document.getElementById('modal-budget-settings').classList.add('show');
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
loadBudgetSettingsData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBudgetSettings() {
|
||||||
|
document.getElementById('modal-budget-settings').classList.remove('show');
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('modal-budget-settings')?.addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'modal-budget-settings') closeBudgetSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
function switchBsTab(tabId, btnEl) {
|
||||||
|
document.querySelectorAll('.bs-tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.bs-pane').forEach(p => p.classList.remove('active'));
|
||||||
|
btnEl.classList.add('active');
|
||||||
|
document.getElementById('pane-' + tabId).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBudgetSettingsData() {
|
||||||
|
try {
|
||||||
|
const response = await pachaFetch('/modules/budget/includes/api/settings.php?action=get_all', { method: 'GET' });
|
||||||
|
if (!response.success) throw new Error(response.error || tr('error_occured'));
|
||||||
|
renderAccounts(response.data.accounts);
|
||||||
|
renderCategories(response.data.categories);
|
||||||
|
populateRuleCategories(response.data.categories);
|
||||||
|
renderRules(response.data.rules);
|
||||||
|
renderSalaries(response.data.salaries, response.data.year);
|
||||||
|
const currencySelect = document.getElementById('bs-currency-select');
|
||||||
|
if (currencySelect && response.data.currency) currencySelect.value = response.data.currency;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erreur chargement paramètres :", err);
|
||||||
|
const errorMsg = `<div class="pf-alert pf-alert--error bs-input-mb">❌ ${tr('error_loading_settings')} : ${err.message}</div>`;
|
||||||
|
['accounts', 'categories', 'rules', 'salaries'].forEach(id => {
|
||||||
|
const el = document.getElementById('bs-list-' + id);
|
||||||
|
if (el) el.innerHTML = errorMsg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. ONGLET : COMPTES
|
||||||
|
function renderAccounts(accounts) {
|
||||||
|
const container = document.getElementById('bs-list-accounts');
|
||||||
|
if (!container) return;
|
||||||
|
if (!accounts || accounts.length === 0) {
|
||||||
|
container.innerHTML = `<em class="text-muted">${tr('bs_empty_accounts')}</em>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
accounts.forEach(acc => {
|
||||||
|
const typeBadge = acc.account_type === 'checking' ? tr('bs_type_checking') : tr('bs_type_savings');
|
||||||
|
const defaultBadge = acc.is_default == 1 ? `<span class="bs-badge-default">${tr('bs_badge_default')}</span>` : '';
|
||||||
|
const ownerText = acc.owner_name ? `${tr('bs_owner')} : ${acc.owner_name}` : tr('bs_shared_account');
|
||||||
|
html += `
|
||||||
|
<div class="bs-list-item">
|
||||||
|
<div>
|
||||||
|
<strong>${acc.name}</strong> ${defaultBadge}<br>
|
||||||
|
<small class="text-muted">${typeBadge} · ${ownerText}</small>
|
||||||
|
</div>
|
||||||
|
<div class="pf-flex-gap-8">
|
||||||
|
<button type="button" class="btn-icon-action delete" onclick="deleteAccount(${acc.id})" title="${tr('btn_delete')}">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ONGLET : CATÉGORIES
|
||||||
|
function renderCategories(categories) {
|
||||||
|
const container = document.getElementById('bs-list-categories');
|
||||||
|
if (!container) return;
|
||||||
|
if (!categories || categories.length === 0) {
|
||||||
|
container.innerHTML = `<em class="text-muted">${tr('bs_empty_categories')}</em>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tri JS pour respecter l'ordre au chargement de la modale
|
||||||
|
categories.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === 'Income' ? -1 : 1;
|
||||||
|
return (a.sort_order || 0) - (b.sort_order || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
categories.forEach(cat => {
|
||||||
|
let typeLabel = cat.type === 'Income' ? tr('bs_type_income') : cat.type === 'Expense' ? tr('bs_type_expense') : tr('bs_type_savings_cat');
|
||||||
|
html += `
|
||||||
|
<div class="bs-list-item" data-id="${cat.id}" style="border-left: 4px solid ${cat.color || '#ccc'};">
|
||||||
|
<div>
|
||||||
|
<span style="font-size:1.2rem; margin-right:8px;">${cat.icon || '📌'}</span>
|
||||||
|
<strong>${cat.label}</strong> <small class="text-muted">(${cat.code} — ${typeLabel})</small>
|
||||||
|
</div>
|
||||||
|
<div class="pf-flex-gap-8">
|
||||||
|
<button type="button" class="btn-icon-action" onclick="moveCatUp(this)" title="Monter">⬆️</button>
|
||||||
|
<button type="button" class="btn-icon-action" onclick="moveCatDown(this)" title="Descendre">⬇️</button>
|
||||||
|
<button type="button" class="btn-icon-action delete" onclick="deleteCategory(${cat.id})" title="${tr('btn_delete')}">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nouvelles fonctions pour réordonner visuellement et sauvegarder
|
||||||
|
function moveCatUp(btn) {
|
||||||
|
let row = btn.closest('.bs-list-item');
|
||||||
|
if (row.previousElementSibling) {
|
||||||
|
row.parentNode.insertBefore(row, row.previousElementSibling);
|
||||||
|
saveCategoriesOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCatDown(btn) {
|
||||||
|
let row = btn.closest('.bs-list-item');
|
||||||
|
if (row.nextElementSibling) {
|
||||||
|
row.parentNode.insertBefore(row.nextElementSibling, row);
|
||||||
|
saveCategoriesOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCategoriesOrder() {
|
||||||
|
let ids = Array.from(document.querySelectorAll('#bs-list-categories .bs-list-item')).map(el => el.dataset.id);
|
||||||
|
let fd = new FormData();
|
||||||
|
fd.append('action', 'save_categories_order');
|
||||||
|
fd.append('order', JSON.stringify(ids));
|
||||||
|
try {
|
||||||
|
await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erreur de sauvegarde de l'ordre :", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ONGLET : RÈGLES D'IMPORT
|
||||||
|
function renderRules(rules) {
|
||||||
|
const container = document.getElementById('bs-list-rules');
|
||||||
|
if (!container) return;
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
container.innerHTML = `<em class="text-muted">${tr('bs_empty_rules')}</em>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groupedRules = {};
|
||||||
|
rules.forEach(r => {
|
||||||
|
const catName = r.cat_label || r.category;
|
||||||
|
if (!groupedRules[catName]) groupedRules[catName] = [];
|
||||||
|
groupedRules[catName].push(r);
|
||||||
|
});
|
||||||
|
let html = '';
|
||||||
|
for (const [catName, catRules] of Object.entries(groupedRules)) {
|
||||||
|
html += `
|
||||||
|
<details class="pf-accordion js-rule-group" data-catname="${catName.toLowerCase()}">
|
||||||
|
<summary class="pf-accordion-summary bs-accordion-summary">
|
||||||
|
<span>📁 ${catName}</span>
|
||||||
|
<span class="bs-accordion-count">${catRules.length}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="pf-accordion-content" style="padding: 0.5rem;">
|
||||||
|
<div class="bs-rule-tags">`;
|
||||||
|
catRules.forEach(r => {
|
||||||
|
html += `<div class="bs-rule-tag js-rule-tag" data-keyword="${r.keyword.toLowerCase()}">
|
||||||
|
${r.keyword}
|
||||||
|
<button type="button" class="bs-rule-tag-del" onclick="deleteRule(${r.id})" title="${tr('btn_delete')}">×</button>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += ` </div>
|
||||||
|
</div>
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRules() {
|
||||||
|
const searchVal = document.getElementById('input-search-rules').value.toLowerCase();
|
||||||
|
document.querySelectorAll('.js-rule-group').forEach(group => {
|
||||||
|
let hasVisibleTag = false;
|
||||||
|
const catName = group.getAttribute('data-catname');
|
||||||
|
group.querySelectorAll('.js-rule-tag').forEach(tag => {
|
||||||
|
const keyword = tag.getAttribute('data-keyword');
|
||||||
|
if (keyword.includes(searchVal)) { tag.style.display = 'inline-flex'; hasVisibleTag = true; }
|
||||||
|
else { tag.style.display = 'none'; }
|
||||||
|
});
|
||||||
|
if (catName.includes(searchVal) && searchVal.length > 0) {
|
||||||
|
group.querySelectorAll('.js-rule-tag').forEach(tag => tag.style.display = 'inline-flex');
|
||||||
|
hasVisibleTag = true;
|
||||||
|
}
|
||||||
|
if (hasVisibleTag) {
|
||||||
|
group.style.display = 'block';
|
||||||
|
if (searchVal.length > 0) group.setAttribute('open', '');
|
||||||
|
} else {
|
||||||
|
group.style.display = 'none'; group.removeAttribute('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. ONGLET : SALAIRES
|
||||||
|
function renderSalaries(salaries, year) {
|
||||||
|
const container = document.getElementById('bs-list-salaries');
|
||||||
|
if (!container) return;
|
||||||
|
if (!salaries || salaries.length === 0) {
|
||||||
|
container.innerHTML = `<em class="text-muted">${tr('bs_empty_salaries')} ${year}.</em>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currency = '<?= CURRENCY ?>';
|
||||||
|
let html = `<p class="bs-desc">${tr('bs_salaries_year_desc')} <strong>${year}</strong>.</p>`;
|
||||||
|
salaries.forEach(s => {
|
||||||
|
html += `
|
||||||
|
<div class="bs-list-item" id="salary-item-${s.id}">
|
||||||
|
<div>
|
||||||
|
<strong>${s.person}</strong><br>
|
||||||
|
<small class="text-muted">
|
||||||
|
${tr('bs_salary_net')} : ${s.salary} ${currency}<br>
|
||||||
|
${tr('bs_salary_fees')} : ${s.mensualite} ${currency}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="pf-flex-gap-8">
|
||||||
|
<button type="button" class="btn-icon-action edit" data-id="${s.id}" data-person="${s.person}" data-salary="${s.salary}" data-mensualite="${s.mensualite}" onclick="inlineEditSalary(this)" title="${tr('btn_edit')}">✏️</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CRUD ACTIONS ---
|
||||||
|
async function deleteAccount(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete'))) return; try { const fd = new FormData(); fd.append('action', 'delete_account'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
|
||||||
|
const formAddAccount = document.getElementById('form-add-account');
|
||||||
|
if (formAddAccount) formAddAccount.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddAccount.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddAccount); fd.append('action', 'add_account'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddAccount.reset(); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
|
||||||
|
|
||||||
|
async function deleteCategory(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete_cat'))) return; try { const fd = new FormData(); fd.append('action', 'delete_category'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
|
||||||
|
const formAddCategory = document.getElementById('form-add-category');
|
||||||
|
if (formAddCategory) formAddCategory.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddCategory.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddCategory); fd.append('action', 'add_category'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddCategory.reset(); formAddCategory.querySelector('input[type="color"]').value = "#3b82f6"; await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
|
||||||
|
|
||||||
|
function populateRuleCategories(categories) { const select = document.getElementById('select-rule-category'); if (!select) return; select.innerHTML = ''; if (!categories) return; categories.forEach(cat => { const opt = document.createElement('option'); opt.value = cat.code; opt.textContent = `${cat.icon || ''} ${cat.label}`; select.appendChild(opt); }); }
|
||||||
|
async function deleteRule(id) { if (!await pachaConfirm(tr('btn_delete'), tr('bs_confirm_delete_rule'))) return; try { const fd = new FormData(); fd.append('action', 'delete_rule'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
|
||||||
|
const formAddRule = document.getElementById('form-add-rule');
|
||||||
|
if (formAddRule) formAddRule.addEventListener('submit', async (e) => { e.preventDefault(); const btn = formAddRule.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(formAddRule); fd.append('action', 'add_rule'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); formAddRule.reset(); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } });
|
||||||
|
|
||||||
|
function inlineEditSalary(btnEl) {
|
||||||
|
const id = btnEl.dataset.id; const person = btnEl.dataset.person; const salary = btnEl.dataset.salary; const mensualite = btnEl.dataset.mensualite; const currency = '<?= CURRENCY ?>';
|
||||||
|
document.getElementById(`salary-item-${id}`).innerHTML = `
|
||||||
|
<form onsubmit="submitInlineSalary(event, ${id})" class="bs-form-inline align-center">
|
||||||
|
<div class="bs-input-flex"><strong>${person}</strong></div>
|
||||||
|
<div style="display:flex; align-items:center; gap:4px;"><small class="text-muted">${tr('bs_salary_net')}:</small><input type="number" step="0.01" name="salary" class="pf-input bs-input-fixed" value="${salary}" required> ${currency}</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:4px;"><small class="text-muted">${tr('bs_salary_fees')}:</small><input type="number" step="0.01" name="mensualite" class="pf-input bs-input-fixed" value="${mensualite}" required> ${currency}</div>
|
||||||
|
<button type="submit" class="btn btn-secondary">💾</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="loadBudgetSettingsData()">❌</button>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
async function submitInlineSalary(event, id) { event.preventDefault(); const form = event.target; const btn = form.querySelector('button'); btn.innerText = '⏳'; btn.disabled = true; try { const fd = new FormData(form); fd.append('action', 'save_salary'); fd.append('id', id); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); btn.innerText = '💾'; btn.disabled = false; } }
|
||||||
|
|
||||||
|
async function updateBudgetCurrency(selectEl) { try { const fd = new FormData(); fd.append('action', 'save_currency'); fd.append('currency', selectEl.value); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); if (typeof showToast === 'function') { showToast(`${tr('bs_currency_updated')} ✅`); } await loadBudgetSettingsData(); } catch (err) { alert("Erreur : " + err.message); } }
|
||||||
|
|
||||||
|
// CSV
|
||||||
|
function toggleCsvAmountCols(type) { if (type === 'split') { document.getElementById('wrapper_col_credit').style.display = 'block'; document.getElementById('lbl_col_debit').innerText = tr('bs_csv_col_debit'); } else { document.getElementById('wrapper_col_credit').style.display = 'none'; document.getElementById('lbl_col_debit').innerText = tr('bs_csv_col_amount'); } }
|
||||||
|
function renderCsvMapping(mapping) { if (!mapping) return; document.getElementById('csv_delimiter').value = mapping.delimiter || ';'; document.getElementById('csv_date_format').value = mapping.date_format || 'd/m/Y'; document.getElementById('csv_col_date').value = mapping.col_date ?? 0; document.getElementById('csv_col_label').value = mapping.col_label ?? 1; document.getElementById('csv_col_ref').value = mapping.col_ref ?? 3; const amountType = mapping.amount_type || 'split'; document.getElementById('csv_amount_type').value = amountType; document.getElementById('csv_col_debit').value = mapping.col_debit ?? 8; document.getElementById('csv_col_credit').value = mapping.col_credit ?? 9; toggleCsvAmountCols(amountType); }
|
||||||
|
async function saveCsvMapping(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('button'); const oldText = btn.innerText; btn.innerText = '⏳...'; btn.disabled = true; try { const fd = new FormData(form); fd.append('action', 'save_csv_mapping'); await pachaFetch('/modules/budget/includes/api/settings.php', { method: 'POST', body: fd }); if (typeof showToast === 'function') showToast(tr('bs_csv_saved')); else alert(tr('bs_csv_saved')); } catch (err) { alert(tr('error_occured') + " : " + err.message); } finally { btn.innerText = oldText; btn.disabled = false; } }
|
||||||
|
|
||||||
|
const dropZone = document.getElementById('csv-drop-zone'); const fileInput = document.getElementById('csv_file_input');
|
||||||
|
if (dropZone && fileInput) {
|
||||||
|
dropZone.addEventListener('click', () => fileInput.click());
|
||||||
|
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.backgroundColor = 'rgba(0, 123, 255, 0.05)'; dropZone.style.borderColor = 'var(--primary-color, #007bff)'; });
|
||||||
|
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.style.backgroundColor = ''; dropZone.style.borderColor = 'var(--border-light, #ccc)'; });
|
||||||
|
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.style.backgroundColor = ''; dropZone.style.borderColor = 'var(--border-light, #ccc)'; if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; handleCsvUpload(e.dataTransfer.files[0]); } });
|
||||||
|
fileInput.addEventListener('change', function() { if (this.files.length) { handleCsvUpload(this.files[0]); } });
|
||||||
|
}
|
||||||
|
function handleCsvUpload(file) {
|
||||||
|
if (!file.name.endsWith('.csv')) { alert(tr('bs_csv_err_type')); return; }
|
||||||
|
if (typeof showToast === 'function') { showToast(`${tr('bs_csv_file_ok')} : ${file.name}`); }
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
const text = e.target.result; const delimiter = document.getElementById('csv_delimiter').value || ';';
|
||||||
|
const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
|
||||||
|
if (lines.length > 0) {
|
||||||
|
const headers = lines[0].split(delimiter).map(h => h.replace(/^"|"$/g, '').trim());
|
||||||
|
const sampleRows = [];
|
||||||
|
for (let i = 1; i < Math.min(lines.length, 3); i++) { sampleRows.push(lines[i].split(delimiter).map(d => d.replace(/^"|"$/g, '').trim())); }
|
||||||
|
renderCsvTablePreview(headers, sampleRows);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file, 'ISO-8859-1');
|
||||||
|
}
|
||||||
|
function renderCsvTablePreview(headers, sampleRows) {
|
||||||
|
const container = document.getElementById('csv-preview-container');
|
||||||
|
let html = `<h5 class="bs-section-subtitle">👁️ ${tr('bs_csv_preview_title')}</h5>`;
|
||||||
|
html += `<div class="bs-csv-table-wrapper"><table class="bs-csv-table"><thead class="bs-csv-thead"><tr>`;
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
const cleanHeader = header || `(${tr('bs_csv_empty_col')})`;
|
||||||
|
html += `<th class="bs-csv-th"><div class="bs-csv-th-index">N° ${index}</div><div>${cleanHeader}</div></th>`;
|
||||||
|
});
|
||||||
|
html += `</tr></thead><tbody>`;
|
||||||
|
if (sampleRows.length === 0) { html += `<tr><td colspan="${headers.length}" class="bs-csv-td-empty">${tr('bs_csv_no_data')}</td></tr>`; }
|
||||||
|
else { sampleRows.forEach(row => { html += `<tr class="bs-csv-tr">`; for (let i = 0; i < headers.length; i++) { const cellData = row[i] !== undefined ? row[i] : ''; const displayData = cellData.length > 40 ? cellData.substring(0, 40) + '...' : cellData; html += `<td class="bs-csv-td">${displayData}</td>`; } html += `</tr>`; }); }
|
||||||
|
html += `</tbody></table></div>`;
|
||||||
|
container.innerHTML = html; container.style.display = 'block';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/meta_db.php';
|
||||||
|
require_once __DIR__ . '/caldav_sync.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$userId = (int)($_SESSION['user']['id'] ?? 0);
|
||||||
|
$familyId = (int)($_SESSION['user']['family_id'] ?? 0);
|
||||||
|
|
||||||
|
$meta_pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS user_calendar_integrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL DEFAULT 'icloud_caldav',
|
||||||
|
username VARCHAR(255) NOT NULL,
|
||||||
|
secret_encrypted TEXT NOT NULL,
|
||||||
|
dav_principal_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
status VARCHAR(30) DEFAULT 'connected',
|
||||||
|
last_sync_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_user_provider (user_id, provider)
|
||||||
|
)");
|
||||||
|
try {
|
||||||
|
$meta_pdo->exec('ALTER TABLE user_calendar_integrations ADD COLUMN calendar_prefs_json TEXT NULL');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// colonne déjà présente
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_events (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
family_id INT NOT NULL,
|
||||||
|
created_by_user_id INT NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT DEFAULT NULL,
|
||||||
|
location VARCHAR(255) DEFAULT NULL,
|
||||||
|
start_at DATETIME NOT NULL,
|
||||||
|
end_at DATETIME NOT NULL,
|
||||||
|
is_all_day TINYINT(1) DEFAULT 0,
|
||||||
|
timezone VARCHAR(64) DEFAULT 'Europe/Paris',
|
||||||
|
rrule VARCHAR(500) DEFAULT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'confirmed',
|
||||||
|
external_uid VARCHAR(255) DEFAULT NULL,
|
||||||
|
sync_state VARCHAR(30) DEFAULT 'pending_push',
|
||||||
|
deleted_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_external_uid (external_uid)
|
||||||
|
)");
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
calendar_event_id INT NOT NULL,
|
||||||
|
external_uid VARCHAR(255) NOT NULL,
|
||||||
|
external_etag VARCHAR(255) DEFAULT NULL,
|
||||||
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
external_href VARCHAR(2048) DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_calendar_event (calendar_event_id),
|
||||||
|
UNIQUE KEY uq_external_link (external_uid)
|
||||||
|
)");
|
||||||
|
try {
|
||||||
|
$pdo->exec('ALTER TABLE pf_calendar_event_links ADD COLUMN external_href VARCHAR(2048) DEFAULT NULL');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// colonne déjà présente
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_ok($data): void { echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE); exit; }
|
||||||
|
function ios_err(string $message, int $status = 400): void { http_response_code($status); echo json_encode(['ok' => false, 'error' => $message]); exit; }
|
||||||
|
function ios_body(): array { return json_decode(file_get_contents('php://input'), true) ?? []; }
|
||||||
|
function ios_require_csrf(): void {
|
||||||
|
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
|
||||||
|
if (!verify_csrf($token)) {
|
||||||
|
ios_err('Token CSRF invalide', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_normalize_datetime(?string $s): ?string
|
||||||
|
{
|
||||||
|
if ($s === null || $s === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$s = str_replace('T', ' ', trim($s));
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $s)) {
|
||||||
|
$s .= ':00';
|
||||||
|
}
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_calendar_url_key(?string $url): string
|
||||||
|
{
|
||||||
|
if ($url === null || $url === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return rtrim(trim($url), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{visible:bool, color:?string}> */
|
||||||
|
function ios_calendar_prefs_decode(?string $json): array
|
||||||
|
{
|
||||||
|
if ($json === null || $json === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$d = json_decode($json, true);
|
||||||
|
if (!is_array($d)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($d as $k => $v) {
|
||||||
|
if (!is_string($k) || $k === '' || !is_array($v)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = ios_calendar_url_key($k);
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$key] = [
|
||||||
|
'visible' => !array_key_exists('visible', $v) ? true : (bool) $v['visible'],
|
||||||
|
'color' => (isset($v['color']) && is_string($v['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $v['color'])) ? $v['color'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{url:string,url_key:string,display:string,visible:bool,color:?string}>
|
||||||
|
*/
|
||||||
|
function ios_list_calendar_sources_for_ui(array $integration, string $password, PDO $pdo): array
|
||||||
|
{
|
||||||
|
$prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
|
||||||
|
$urlsMeta = [];
|
||||||
|
$primary = $integration['calendar_url'] ?? '';
|
||||||
|
if (hh_caldav_url_is_icloud($primary)) {
|
||||||
|
try {
|
||||||
|
foreach (hh_icloud_discover_calendar_entries($integration['username'], $password) as $e) {
|
||||||
|
$k = ios_calendar_url_key($e['url']);
|
||||||
|
if ($k === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$disp = trim((string) ($e['display'] ?? ''));
|
||||||
|
if ($disp === '') {
|
||||||
|
$disp = basename(parse_url($e['url'], PHP_URL_PATH) ?: '') ?: $k;
|
||||||
|
}
|
||||||
|
$urlsMeta[$k] = ['url' => rtrim($e['url'], '/') . '/', 'display' => $disp];
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$k = ios_calendar_url_key($primary);
|
||||||
|
if ($k !== '') {
|
||||||
|
$urlsMeta[$k] = ['url' => rtrim($primary, '/') . '/', 'display' => 'Calendrier'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT DISTINCT l.calendar_url AS u
|
||||||
|
FROM pf_calendar_event_links l
|
||||||
|
INNER JOIN pf_calendar_events e ON e.id = l.calendar_event_id
|
||||||
|
WHERE e.deleted_at IS NULL AND l.calendar_url IS NOT NULL AND TRIM(l.calendar_url) != ''
|
||||||
|
");
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $u) {
|
||||||
|
$k = ios_calendar_url_key((string) $u);
|
||||||
|
if ($k === '' || isset($urlsMeta[$k])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$urlsMeta[$k] = [
|
||||||
|
'url' => rtrim((string) $u, '/') . '/',
|
||||||
|
'display' => basename(parse_url((string) $u, PHP_URL_PATH) ?: '') ?: $k,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($urlsMeta as $key => $meta) {
|
||||||
|
$p = $prefs[$key] ?? [];
|
||||||
|
$visible = !array_key_exists('visible', $p) ? true : (bool) $p['visible'];
|
||||||
|
$color = (isset($p['color']) && is_string($p['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $p['color'])) ? $p['color'] : null;
|
||||||
|
$out[] = [
|
||||||
|
'url' => $meta['url'],
|
||||||
|
'url_key' => $key,
|
||||||
|
'display' => $meta['display'],
|
||||||
|
'visible' => $visible,
|
||||||
|
'color' => $color,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($out, static fn ($a, $b) => strcasecmp($a['display'], $b['display']));
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
$integrationStmt = $meta_pdo->prepare("SELECT * FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'");
|
||||||
|
$integrationStmt->execute([$userId]);
|
||||||
|
$integration = $integrationStmt->fetch();
|
||||||
|
|
||||||
|
if ($action === 'events' && $method === 'GET') {
|
||||||
|
$prefs = [];
|
||||||
|
if ($integration) {
|
||||||
|
$prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
|
||||||
|
}
|
||||||
|
$rows = $pdo->query("
|
||||||
|
SELECT e.*, l.calendar_url AS calendar_source_url
|
||||||
|
FROM pf_calendar_events e
|
||||||
|
LEFT JOIN pf_calendar_event_links l ON l.calendar_event_id = e.id
|
||||||
|
WHERE e.deleted_at IS NULL
|
||||||
|
ORDER BY e.start_at ASC
|
||||||
|
")->fetchAll();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$key = ios_calendar_url_key($r['calendar_source_url'] ?? '');
|
||||||
|
if ($key !== '' && isset($prefs[$key]['visible']) && $prefs[$key]['visible'] === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$r['display_color'] = null;
|
||||||
|
if ($key !== '' && !empty($prefs[$key]['color']) && is_string($prefs[$key]['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $prefs[$key]['color'])) {
|
||||||
|
$r['display_color'] = $prefs[$key]['color'];
|
||||||
|
}
|
||||||
|
$out[] = $r;
|
||||||
|
}
|
||||||
|
ios_ok($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'calendar_sources' && $method === 'GET') {
|
||||||
|
if (!$integration) {
|
||||||
|
ios_ok(['calendars' => []]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$ctx = ios_caldav_prepare($integration, $meta_pdo);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
ios_err('CalDAV: ' . $e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
$integrationStmt->execute([$userId]);
|
||||||
|
$integrationFresh = $integrationStmt->fetch();
|
||||||
|
if (!$integrationFresh) {
|
||||||
|
ios_ok(['calendars' => []]);
|
||||||
|
}
|
||||||
|
ios_ok(['calendars' => ios_list_calendar_sources_for_ui($integrationFresh, $ctx['password'], $pdo)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'calendar_prefs' && $method === 'POST') {
|
||||||
|
ios_require_csrf();
|
||||||
|
if (!$integration) {
|
||||||
|
ios_err('Connexion iCloud non configurée.', 400);
|
||||||
|
}
|
||||||
|
$body = ios_body();
|
||||||
|
$raw = $body['prefs'] ?? null;
|
||||||
|
if (!is_array($raw)) {
|
||||||
|
ios_err('Format prefs invalide', 400);
|
||||||
|
}
|
||||||
|
$clean = [];
|
||||||
|
foreach ($raw as $key => $p) {
|
||||||
|
if (!is_array($p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$urlKey = ios_calendar_url_key(is_string($key) ? $key : '');
|
||||||
|
if ($urlKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$visible = array_key_exists('visible', $p) ? (bool) $p['visible'] : true;
|
||||||
|
$col = $p['color'] ?? null;
|
||||||
|
if ($col !== null && $col !== '' && is_string($col) && preg_match('/^#[0-9A-Fa-f]{6}$/', $col)) {
|
||||||
|
$col = '#' . strtolower(substr($col, 1));
|
||||||
|
} else {
|
||||||
|
$col = null;
|
||||||
|
}
|
||||||
|
$clean[$urlKey] = ['visible' => $visible, 'color' => $col];
|
||||||
|
}
|
||||||
|
$existing = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
|
||||||
|
foreach ($clean as $urlKey => $v) {
|
||||||
|
$existing[$urlKey] = $v;
|
||||||
|
}
|
||||||
|
$json = json_encode($existing, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$meta_pdo->prepare('UPDATE user_calendar_integrations SET calendar_prefs_json = ? WHERE id = ?')->execute([$json, $integration['id']]);
|
||||||
|
ios_ok(['saved' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'events' && $method === 'POST') {
|
||||||
|
ios_require_csrf();
|
||||||
|
$d = ios_body();
|
||||||
|
if (empty($d['title']) || empty($d['start_at']) || empty($d['end_at'])) {
|
||||||
|
ios_err('Champs requis manquants');
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, sync_state)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', 'pending_push')
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$familyId, $userId, trim($d['title']), $d['description'] ?? null, $d['location'] ?? null,
|
||||||
|
ios_normalize_datetime($d['start_at'] ?? null), ios_normalize_datetime($d['end_at'] ?? null),
|
||||||
|
]);
|
||||||
|
ios_ok(['id' => (int)$pdo->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'events' && $method === 'PUT') {
|
||||||
|
ios_require_csrf();
|
||||||
|
$d = ios_body();
|
||||||
|
$id = (int)($d['id'] ?? 0);
|
||||||
|
if ($id <= 0) ios_err('ID invalide');
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE pf_calendar_events
|
||||||
|
SET title=?, description=?, location=?, start_at=?, end_at=?, updated_at=NOW(), sync_state='pending_push'
|
||||||
|
WHERE id=?
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
trim($d['title'] ?? ''), $d['description'] ?? null, $d['location'] ?? null,
|
||||||
|
ios_normalize_datetime($d['start_at'] ?? null), ios_normalize_datetime($d['end_at'] ?? null), $id,
|
||||||
|
]);
|
||||||
|
ios_ok(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'events' && $method === 'DELETE') {
|
||||||
|
ios_require_csrf();
|
||||||
|
$d = ios_body();
|
||||||
|
$id = (int)($d['id'] ?? 0);
|
||||||
|
if ($id <= 0) ios_err('ID invalide');
|
||||||
|
$row = $pdo->prepare("SELECT id FROM pf_calendar_events WHERE id=?");
|
||||||
|
$row->execute([$id]);
|
||||||
|
if (!$row->fetch()) ios_err('Événement introuvable', 404);
|
||||||
|
$pdo->prepare("UPDATE pf_calendar_events SET deleted_at=NOW(), sync_state='pending_delete' WHERE id=?")->execute([$id]);
|
||||||
|
ios_ok(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'sync_status' && $method === 'GET') {
|
||||||
|
if (!$integration) {
|
||||||
|
ios_ok(['message' => 'Aucune intégration iCloud configurée.']);
|
||||||
|
}
|
||||||
|
$msg = 'Connecté iCloud. Dernière synchro: ' . ($integration['last_sync_at'] ?? 'jamais');
|
||||||
|
ios_ok(['message' => $msg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'sync' && $method === 'POST') {
|
||||||
|
ios_require_csrf();
|
||||||
|
if (!$integration) ios_err('Connexion iCloud non configurée.', 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctx = ios_caldav_prepare($integration, $meta_pdo);
|
||||||
|
$integration = $ctx['integration'];
|
||||||
|
$davPassword = $ctx['password'];
|
||||||
|
$syncUrls = ios_sync_calendar_collection_urls($integration, $davPassword);
|
||||||
|
$remoteEvents = ios_fetch_remote_events_all_calendars($integration, $davPassword);
|
||||||
|
|
||||||
|
$localStmt = $pdo->query("SELECT * FROM pf_calendar_events WHERE deleted_at IS NULL");
|
||||||
|
$localEvents = $localStmt->fetchAll();
|
||||||
|
|
||||||
|
$linkStmt = $pdo->prepare('SELECT external_href, calendar_url FROM pf_calendar_event_links WHERE calendar_event_id = ?');
|
||||||
|
|
||||||
|
foreach ($localEvents as $evt) {
|
||||||
|
if ($evt['sync_state'] === 'pending_push' || empty($evt['external_uid'])) {
|
||||||
|
$linkStmt->execute([$evt['id']]);
|
||||||
|
$lr = $linkStmt->fetch() ?: [];
|
||||||
|
$push = ios_push_event_to_remote(
|
||||||
|
$integration,
|
||||||
|
$evt,
|
||||||
|
$davPassword,
|
||||||
|
$lr['external_href'] ?? null,
|
||||||
|
$lr['calendar_url'] ?? null
|
||||||
|
);
|
||||||
|
if ($push['code'] >= 200 && $push['code'] < 300) {
|
||||||
|
$uid = $evt['external_uid'] ?: ('hh-' . $evt['id'] . '@househub');
|
||||||
|
$pdo->prepare("UPDATE pf_calendar_events SET external_uid=?, sync_state='synced', updated_at=NOW() WHERE id=?")->execute([$uid, $evt['id']]);
|
||||||
|
$pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag, external_href) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE external_etag=VALUES(external_etag), calendar_url=VALUES(calendar_url), external_href=COALESCE(VALUES(external_href), external_href), updated_at=NOW()")
|
||||||
|
->execute([$evt['id'], $uid, $integration['calendar_url'], null, $lr['external_href'] ?? null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingDelete = $pdo->query("SELECT id, external_uid FROM pf_calendar_events WHERE deleted_at IS NOT NULL AND sync_state='pending_delete'")->fetchAll();
|
||||||
|
foreach ($pendingDelete as $evt) {
|
||||||
|
if (!empty($evt['external_uid'])) {
|
||||||
|
$linkStmt->execute([$evt['id']]);
|
||||||
|
$lr = $linkStmt->fetch() ?: [];
|
||||||
|
ios_delete_remote_event(
|
||||||
|
$integration,
|
||||||
|
$evt['external_uid'],
|
||||||
|
$davPassword,
|
||||||
|
$lr['external_href'] ?? null,
|
||||||
|
$lr['calendar_url'] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$pdo->prepare("DELETE FROM pf_calendar_event_links WHERE calendar_event_id=?")->execute([$evt['id']]);
|
||||||
|
$pdo->prepare("DELETE FROM pf_calendar_events WHERE id=?")->execute([$evt['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($remoteEvents as $remote) {
|
||||||
|
$href = $remote['_resource_url'] ?? null;
|
||||||
|
$colUrl = $remote['_calendar_collection_url'] ?? $integration['calendar_url'];
|
||||||
|
$existing = $pdo->prepare("SELECT id, updated_at FROM pf_calendar_events WHERE external_uid=? LIMIT 1");
|
||||||
|
$existing->execute([$remote['external_uid']]);
|
||||||
|
$row = $existing->fetch();
|
||||||
|
if (!$row) {
|
||||||
|
$pdo->prepare("
|
||||||
|
INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, external_uid, sync_state)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', ?, 'synced')
|
||||||
|
")->execute([$familyId, $userId, $remote['title'], $remote['description'], $remote['location'], $remote['start_at'], $remote['end_at'], $remote['external_uid']]);
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
$pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag, external_href) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE calendar_url=VALUES(calendar_url), external_href=COALESCE(VALUES(external_href), external_href), updated_at=NOW()")
|
||||||
|
->execute([$newId, $remote['external_uid'], $colUrl, null, $href]);
|
||||||
|
} elseif ($href) {
|
||||||
|
$pdo->prepare('UPDATE pf_calendar_event_links SET external_href = COALESCE(external_href, ?), calendar_url = COALESCE(calendar_url, ?) WHERE calendar_event_id = ?')
|
||||||
|
->execute([$href, $colUrl, $row['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta_pdo->prepare("UPDATE user_calendar_integrations SET last_sync_at=NOW(), status='connected' WHERE id=?")->execute([$integration['id']]);
|
||||||
|
$n = count($remoteEvents);
|
||||||
|
$nc = count($syncUrls);
|
||||||
|
ios_ok(['message' => 'Synchronisation terminée. ' . $n . ' événement(s) depuis ' . $nc . ' calendrier(s) distant(s).']);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
ios_err('CalDAV: ' . $e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ios_err('Action inconnue', 404);
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
/* ─── Page layout (cartes claires hors agenda) ─────────────────────────── */
|
||||||
|
.ios-calendar-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-calendar-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-calendar-head h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-intro {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--pf-text-muted, #64748b);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-prefs-details {
|
||||||
|
margin-top: 14px;
|
||||||
|
border: 1px solid var(--pf-border, #e2e8f0);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--pf-bg-page, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-prefs-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-prefs-help {
|
||||||
|
margin: 10px 0 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--pf-text-muted, #64748b);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-prefs-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--pf-border, #e2e8f0);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--pf-bg-lighter, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-row input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-label {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-color {
|
||||||
|
width: 44px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--pf-border, #e2e8f0);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--pf-bg-page, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-reset {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--pf-text-muted, #64748b);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-reset:hover {
|
||||||
|
color: var(--pf-text-main, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-prefs-msg {
|
||||||
|
min-height: 1.25em;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ios-cal-pref-row {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
.ios-cal-pref-color {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
.ios-cal-pref-reset {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-form-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-events-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-card {
|
||||||
|
border: 1px solid var(--pf-border, #e2e8f0);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--pf-bg-page, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-card-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-card-meta {
|
||||||
|
color: var(--pf-text-muted, #64748b);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Agenda sombre (style type app calendrier) ─────────────────────────── */
|
||||||
|
.ios-agenda-card {
|
||||||
|
--ios-bg: #0a0a0c;
|
||||||
|
--ios-elev: #12121a;
|
||||||
|
--ios-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--ios-text: #f1f5f9;
|
||||||
|
--ios-muted: #94a3b8;
|
||||||
|
--ios-now: #ef4444;
|
||||||
|
--ios-seg-bg: #1a1a22;
|
||||||
|
background: var(--ios-bg);
|
||||||
|
border: 1px solid var(--ios-border);
|
||||||
|
color: var(--ios-text);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--ios-border);
|
||||||
|
background: var(--ios-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: capitalize;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-segments {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--ios-seg-bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-seg {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-seg:hover {
|
||||||
|
color: var(--ios-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-seg--active {
|
||||||
|
background: #2d2d38;
|
||||||
|
color: var(--ios-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-btn {
|
||||||
|
border: 1px solid var(--ios-border);
|
||||||
|
background: var(--ios-elev);
|
||||||
|
color: var(--ios-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-btn:hover {
|
||||||
|
background: #1e1e28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-today {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ios-now);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-add {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--ios-border);
|
||||||
|
background: var(--ios-elev);
|
||||||
|
color: var(--ios-text);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-add:hover {
|
||||||
|
background: #1e1e28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-listlink {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-listlink:hover {
|
||||||
|
color: var(--ios-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-mount {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-list-hint {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 16px;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grille semaine / jour */
|
||||||
|
.ios-week-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px repeat(7, minmax(0, 1fr));
|
||||||
|
border-bottom: 1px solid var(--ios-border);
|
||||||
|
background: var(--ios-elev);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-head--day {
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-corner {
|
||||||
|
border-right: 1px solid var(--ios-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-head-day {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 4px;
|
||||||
|
border-left: 1px solid var(--ios-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-head-num {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ios-text);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-head-day--today .ios-week-head-num {
|
||||||
|
background: var(--ios-now);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-scroll {
|
||||||
|
max-height: min(70vh, 720px);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-track {
|
||||||
|
display: flex;
|
||||||
|
min-width: min(100%, 720px);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-gutter {
|
||||||
|
width: 52px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--ios-border);
|
||||||
|
background: var(--ios-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-gutter-cell {
|
||||||
|
height: 44px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 8px;
|
||||||
|
line-height: 44px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-cols-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: 704px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-cols {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 704px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-cols--day {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-col {
|
||||||
|
border-left: 1px solid var(--ios-border);
|
||||||
|
background-color: var(--ios-bg);
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
transparent 43px,
|
||||||
|
var(--ios-border) 43px,
|
||||||
|
var(--ios-border) 44px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-col:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-col-inner {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-block {
|
||||||
|
position: absolute;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-block--custom {
|
||||||
|
border: none !important;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-block-title {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-c0 {
|
||||||
|
background: linear-gradient(135deg, #166534, #15803d);
|
||||||
|
}
|
||||||
|
.ios-cal-c1 {
|
||||||
|
background: linear-gradient(135deg, #1e40af, #2563eb);
|
||||||
|
}
|
||||||
|
.ios-cal-c2 {
|
||||||
|
background: linear-gradient(135deg, #6b21a8, #7c3aed);
|
||||||
|
}
|
||||||
|
.ios-cal-c3 {
|
||||||
|
background: linear-gradient(135deg, #a16207, #ca8a04);
|
||||||
|
}
|
||||||
|
.ios-cal-c4 {
|
||||||
|
background: linear-gradient(135deg, #0f766e, #14b8a6);
|
||||||
|
}
|
||||||
|
.ios-cal-c5 {
|
||||||
|
background: linear-gradient(135deg, #9f1239, #e11d48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-now-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-now-line-span {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ios-now);
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-now-lbl-span {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ios-now);
|
||||||
|
text-shadow: 0 0 6px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mois (dans carte sombre) */
|
||||||
|
.ios-month-agenda {
|
||||||
|
padding: 12px 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-agenda-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-agenda-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid var(--ios-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--ios-elev);
|
||||||
|
color: var(--ios-text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 4px 2px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-cell--pad {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-cell--today {
|
||||||
|
border-color: var(--ios-now);
|
||||||
|
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-cell--has {
|
||||||
|
background: #1a1a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-num {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ios-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.ios-calendar-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.ios-event-card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.ios-agenda-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.ios-agenda-segments {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ios-agenda-nav {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,700 @@
|
|||||||
|
const iosEventsList = document.getElementById("ios-events-list");
|
||||||
|
const iosSyncStatus = document.getElementById("ios-sync-status");
|
||||||
|
const iosForm = document.getElementById("ios-event-form");
|
||||||
|
const iosAgendaMount = document.getElementById("ios-agenda-mount");
|
||||||
|
const iosAgendaTitle = document.getElementById("ios-agenda-title");
|
||||||
|
|
||||||
|
const H_START = 6;
|
||||||
|
const H_END = 22;
|
||||||
|
const PX_PER_H = 44;
|
||||||
|
const TRACK_H = (H_END - H_START) * PX_PER_H;
|
||||||
|
|
||||||
|
let iosEventsCache = [];
|
||||||
|
/** @type {'day'|'week'|'month'} */
|
||||||
|
let iosViewMode = "week";
|
||||||
|
/** Ancre de navigation (jour courant dans la période affichée) */
|
||||||
|
let iosCursor = new Date();
|
||||||
|
let iosNowTimer = null;
|
||||||
|
let iosListVisible = false;
|
||||||
|
|
||||||
|
const PALETTE = [
|
||||||
|
"ios-cal-c0",
|
||||||
|
"ios-cal-c1",
|
||||||
|
"ios-cal-c2",
|
||||||
|
"ios-cal-c3",
|
||||||
|
"ios-cal-c4",
|
||||||
|
"ios-cal-c5",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUGGEST_HEX = ["#22c55e", "#3b82f6", "#8b5cf6", "#eab308", "#14b8a6", "#f43f5e"];
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s ?? "";
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqlToDatetimeLocal(s) {
|
||||||
|
if (!s) return "";
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})[\sT](\d{2}):(\d{2})/.exec(String(s).trim());
|
||||||
|
if (!m) return "";
|
||||||
|
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDatetimeToSql(s) {
|
||||||
|
if (!s) return "";
|
||||||
|
const t = s.includes("T") ? s.replace("T", " ") : s;
|
||||||
|
return t.length === 16 ? `${t}:00` : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvtDate(iso) {
|
||||||
|
const d = new Date(String(iso).replace(" ", "T"));
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function darkenHex(hex, amount) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const r = Math.max(0, (n >> 16) - amount);
|
||||||
|
const g = Math.max(0, ((n >> 8) & 0xff) - amount);
|
||||||
|
const b = Math.max(0, (n & 0xff) - amount);
|
||||||
|
return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestedColorForKey(key) {
|
||||||
|
const k = String(key || "");
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) >>> 0;
|
||||||
|
return SUGGEST_HEX[h % SUGGEST_HEX.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorClassForEvent(evt) {
|
||||||
|
const key = String(evt.calendar_source_url || evt.external_uid || evt.id || "");
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
|
||||||
|
return PALETTE[h % PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applique couleur perso (API) ou palette par défaut */
|
||||||
|
function styleEventBlock(el, evt) {
|
||||||
|
el.className = "ios-event-block";
|
||||||
|
el.style.background = "";
|
||||||
|
el.style.boxShadow = "";
|
||||||
|
const hex = evt.display_color;
|
||||||
|
if (hex && /^#[0-9A-Fa-f]{6}$/i.test(hex)) {
|
||||||
|
const c = hex.startsWith("#") ? hex : `#${hex.slice(-6)}`;
|
||||||
|
el.classList.add("ios-event-block--custom");
|
||||||
|
el.style.background = `linear-gradient(135deg, ${darkenHex(c, 40)} 0%, ${c} 100%)`;
|
||||||
|
el.style.boxShadow = "inset 3px 0 0 rgba(255,255,255,0.35)";
|
||||||
|
} else {
|
||||||
|
el.classList.add(colorClassForEvent(evt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mondayOf(date) {
|
||||||
|
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
const dow = d.getDay();
|
||||||
|
const diff = dow === 0 ? -6 : 1 - dow;
|
||||||
|
d.setDate(d.getDate() + diff);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(d, n) {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setDate(x.getDate() + n);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(d, n) {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setMonth(x.getMonth() + n);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameLocalDay(a, b) {
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segment d’un événement visible dans [dayMidnight, dayMidnight+1j[ */
|
||||||
|
function segmentInLocalDay(evt, dayMidnight) {
|
||||||
|
const s = parseEvtDate(evt.start_at);
|
||||||
|
const e = parseEvtDate(evt.end_at);
|
||||||
|
if (!s || !e) return null;
|
||||||
|
const dayEnd = addDays(dayMidnight, 1);
|
||||||
|
if (e <= dayMidnight || s >= dayEnd) return null;
|
||||||
|
const segStart = s > dayMidnight ? s : dayMidnight;
|
||||||
|
const segEnd = e < dayEnd ? e : dayEnd;
|
||||||
|
return { segStart, segEnd, evt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pxInTrackFromHourFraction(dayMidnight, t) {
|
||||||
|
const grid0 = new Date(dayMidnight);
|
||||||
|
grid0.setHours(H_START, 0, 0, 0);
|
||||||
|
const grid1 = new Date(dayMidnight);
|
||||||
|
grid1.setHours(H_END, 0, 0, 0);
|
||||||
|
const tt = Math.min(Math.max(t.getTime(), grid0.getTime()), grid1.getTime());
|
||||||
|
return ((tt - grid0.getTime()) / 3600000) * PX_PER_H;
|
||||||
|
}
|
||||||
|
|
||||||
|
function heightPxForSegment(dayMidnight, segStart, segEnd) {
|
||||||
|
const a = pxInTrackFromHourFraction(dayMidnight, segStart);
|
||||||
|
const b = pxInTrackFromHourFraction(dayMidnight, segEnd);
|
||||||
|
return Math.max(18, b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowLineTopPx(colDay) {
|
||||||
|
const now = new Date();
|
||||||
|
if (!sameLocalDay(now, colDay)) return null;
|
||||||
|
return pxInTrackFromHourFraction(colDay, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignLanes(segments) {
|
||||||
|
if (segments.length === 0) return;
|
||||||
|
segments.sort((a, b) => a.segStart - b.segStart);
|
||||||
|
const laneEnds = [];
|
||||||
|
segments.forEach((seg) => {
|
||||||
|
let L = 0;
|
||||||
|
while (laneEnds[L] && seg.segStart < laneEnds[L]) L++;
|
||||||
|
laneEnds[L] = seg.segEnd;
|
||||||
|
seg._lane = L;
|
||||||
|
});
|
||||||
|
const maxL = segments.reduce((m, s) => Math.max(m, s._lane), 0);
|
||||||
|
segments.forEach((s) => {
|
||||||
|
s._laneCount = maxL + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function iosApi(action, options = {}) {
|
||||||
|
const method = options.method || "GET";
|
||||||
|
const headers = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
...(options.headers || {}),
|
||||||
|
};
|
||||||
|
if (method !== "GET") {
|
||||||
|
headers["X-CSRF-Token"] = window.CSRF_TOKEN || "";
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
const response = await fetch(`/modules/calendar-ios/api.php?action=${encodeURIComponent(action)}`, {
|
||||||
|
method,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers,
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
});
|
||||||
|
const json = await response.json();
|
||||||
|
if (!json.ok) throw new Error(json.error || "Erreur inconnue");
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
const d = parseEvtDate(iso);
|
||||||
|
return d ? d.toLocaleString("fr-FR") : iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillForm(evt) {
|
||||||
|
document.getElementById("ios-event-id").value = evt.id;
|
||||||
|
document.getElementById("ios-title").value = evt.title || "";
|
||||||
|
document.getElementById("ios-location").value = evt.location || "";
|
||||||
|
document.getElementById("ios-description").value = evt.description || "";
|
||||||
|
document.getElementById("ios-start").value = sqlToDatetimeLocal(evt.start_at);
|
||||||
|
document.getElementById("ios-end").value = sqlToDatetimeLocal(evt.end_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
iosForm.reset();
|
||||||
|
document.getElementById("ios-event-id").value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventsForLocalDay(y, m, d) {
|
||||||
|
return iosEventsCache.filter((evt) => {
|
||||||
|
const t = parseEvtDate(evt.start_at);
|
||||||
|
return t && t.getFullYear() === y && t.getMonth() === m && t.getDate() === d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateToolbarTitle() {
|
||||||
|
if (!iosAgendaTitle) return;
|
||||||
|
if (iosViewMode === "day") {
|
||||||
|
iosAgendaTitle.textContent = iosCursor.toLocaleDateString("fr-FR", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (iosViewMode === "week") {
|
||||||
|
const mon = mondayOf(iosCursor);
|
||||||
|
const sun = addDays(mon, 6);
|
||||||
|
if (mon.getMonth() === sun.getMonth() && mon.getFullYear() === sun.getFullYear()) {
|
||||||
|
iosAgendaTitle.textContent = `${mon.getDate()} – ${sun.getDate()} ${sun.toLocaleDateString("fr-FR", { month: "long", year: "numeric" })}`;
|
||||||
|
} else {
|
||||||
|
iosAgendaTitle.textContent = `${mon.toLocaleDateString("fr-FR", { day: "numeric", month: "short" })} – ${sun.toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" })}`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
iosAgendaTitle.textContent = new Date(iosCursor.getFullYear(), iosCursor.getMonth(), 1).toLocaleDateString("fr-FR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveSegments() {
|
||||||
|
document.querySelectorAll(".ios-seg").forEach((b) => {
|
||||||
|
b.classList.toggle("ios-seg--active", b.getAttribute("data-ios-view") === iosViewMode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeGutter() {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (let h = H_START; h < H_END; h++) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ios-agenda-gutter-cell";
|
||||||
|
row.textContent = `${h} h`;
|
||||||
|
frag.appendChild(row);
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDayColumn(dayMidnight) {
|
||||||
|
const col = document.createElement("div");
|
||||||
|
col.className = "ios-agenda-col";
|
||||||
|
const inner = document.createElement("div");
|
||||||
|
inner.className = "ios-agenda-col-inner";
|
||||||
|
inner.style.height = `${TRACK_H}px`;
|
||||||
|
|
||||||
|
const segs = [];
|
||||||
|
iosEventsCache.forEach((evt) => {
|
||||||
|
const seg = segmentInLocalDay(evt, dayMidnight);
|
||||||
|
if (seg) segs.push(seg);
|
||||||
|
});
|
||||||
|
assignLanes(segs);
|
||||||
|
|
||||||
|
segs.forEach((seg) => {
|
||||||
|
const top = pxInTrackFromHourFraction(dayMidnight, seg.segStart);
|
||||||
|
const h = heightPxForSegment(dayMidnight, seg.segStart, seg.segEnd);
|
||||||
|
const w = 100 / seg._laneCount;
|
||||||
|
const left = (100 * seg._lane) / seg._laneCount;
|
||||||
|
const blk = document.createElement("button");
|
||||||
|
blk.type = "button";
|
||||||
|
styleEventBlock(blk, seg.evt);
|
||||||
|
blk.style.top = `${top}px`;
|
||||||
|
blk.style.height = `${h}px`;
|
||||||
|
blk.style.left = `calc(${left}% + 2px)`;
|
||||||
|
blk.style.width = `calc(${w}% - 4px)`;
|
||||||
|
blk.innerHTML = `<span class="ios-event-block-title">${escHtml(seg.evt.title || "(Sans titre)")}</span>`;
|
||||||
|
blk.title = [seg.evt.title, seg.evt.location, seg.evt.calendar_source_url].filter(Boolean).join("\n");
|
||||||
|
blk.addEventListener("click", () => {
|
||||||
|
fillForm(seg.evt);
|
||||||
|
document.getElementById("ios-edit-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
});
|
||||||
|
inner.appendChild(blk);
|
||||||
|
});
|
||||||
|
|
||||||
|
col.appendChild(inner);
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWeekOrDay() {
|
||||||
|
if (!iosAgendaMount) return;
|
||||||
|
const isDay = iosViewMode === "day";
|
||||||
|
const weekStart = isDay ? new Date(iosCursor.getFullYear(), iosCursor.getMonth(), iosCursor.getDate()) : mondayOf(iosCursor);
|
||||||
|
const days = isDay ? [weekStart] : Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||||
|
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "ios-week-wrap";
|
||||||
|
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "ios-week-head" + (isDay ? " ios-week-head--day" : "");
|
||||||
|
const corner = document.createElement("div");
|
||||||
|
corner.className = "ios-week-corner";
|
||||||
|
head.appendChild(corner);
|
||||||
|
const today = new Date();
|
||||||
|
days.forEach((d) => {
|
||||||
|
const th = document.createElement("div");
|
||||||
|
th.className = "ios-week-head-day";
|
||||||
|
if (sameLocalDay(d, today)) th.classList.add("ios-week-head-day--today");
|
||||||
|
const num = document.createElement("span");
|
||||||
|
num.className = "ios-week-head-num";
|
||||||
|
num.textContent = String(d.getDate());
|
||||||
|
const wd = d.toLocaleDateString("fr-FR", { weekday: "short" }).replace(/\.$/, "");
|
||||||
|
th.appendChild(num);
|
||||||
|
th.appendChild(document.createTextNode(` ${wd}.`));
|
||||||
|
head.appendChild(th);
|
||||||
|
});
|
||||||
|
wrap.appendChild(head);
|
||||||
|
|
||||||
|
const scroll = document.createElement("div");
|
||||||
|
scroll.className = "ios-week-scroll";
|
||||||
|
|
||||||
|
const track = document.createElement("div");
|
||||||
|
track.className = "ios-week-track";
|
||||||
|
|
||||||
|
const gutter = document.createElement("div");
|
||||||
|
gutter.className = "ios-agenda-gutter";
|
||||||
|
gutter.appendChild(renderTimeGutter());
|
||||||
|
track.appendChild(gutter);
|
||||||
|
|
||||||
|
const colsWrap = document.createElement("div");
|
||||||
|
colsWrap.className = "ios-agenda-cols-wrap";
|
||||||
|
|
||||||
|
const cols = document.createElement("div");
|
||||||
|
cols.className = `ios-agenda-cols${isDay ? " ios-agenda-cols--day" : ""}`;
|
||||||
|
days.forEach((d0) => {
|
||||||
|
const dm = new Date(d0.getFullYear(), d0.getMonth(), d0.getDate());
|
||||||
|
cols.appendChild(buildDayColumn(dm));
|
||||||
|
});
|
||||||
|
colsWrap.appendChild(cols);
|
||||||
|
|
||||||
|
const todayCol = days.findIndex((d) => sameLocalDay(d, new Date()));
|
||||||
|
if (todayCol >= 0) {
|
||||||
|
const dm = new Date(days[todayCol].getFullYear(), days[todayCol].getMonth(), days[todayCol].getDate());
|
||||||
|
const nowTop = nowLineTopPx(dm);
|
||||||
|
if (nowTop !== null) {
|
||||||
|
const ov = document.createElement("div");
|
||||||
|
ov.className = "ios-now-overlay";
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "ios-now-line-span";
|
||||||
|
line.style.top = `${nowTop}px`;
|
||||||
|
const lbl = document.createElement("div");
|
||||||
|
lbl.className = "ios-now-lbl-span";
|
||||||
|
lbl.textContent = new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
lbl.style.top = `${Math.max(2, nowTop - 10)}px`;
|
||||||
|
ov.appendChild(line);
|
||||||
|
ov.appendChild(lbl);
|
||||||
|
colsWrap.appendChild(ov);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
track.appendChild(colsWrap);
|
||||||
|
scroll.appendChild(track);
|
||||||
|
wrap.appendChild(scroll);
|
||||||
|
iosAgendaMount.innerHTML = "";
|
||||||
|
iosAgendaMount.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMonthInAgenda() {
|
||||||
|
if (!iosAgendaMount) return;
|
||||||
|
const y = iosCursor.getFullYear();
|
||||||
|
const m = iosCursor.getMonth();
|
||||||
|
const first = new Date(y, m, 1);
|
||||||
|
const last = new Date(y, m + 1, 0);
|
||||||
|
let startWeekday = first.getDay() - 1;
|
||||||
|
if (startWeekday < 0) startWeekday = 6;
|
||||||
|
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "ios-month-agenda";
|
||||||
|
const wk = document.createElement("div");
|
||||||
|
wk.className = "ios-month-agenda-weekdays";
|
||||||
|
["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].forEach((t) => {
|
||||||
|
const s = document.createElement("span");
|
||||||
|
s.textContent = t;
|
||||||
|
wk.appendChild(s);
|
||||||
|
});
|
||||||
|
wrap.appendChild(wk);
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.className = "ios-month-agenda-grid";
|
||||||
|
|
||||||
|
for (let i = 0; i < startWeekday; i++) {
|
||||||
|
const c = document.createElement("div");
|
||||||
|
c.className = "ios-month-cell ios-month-cell--pad";
|
||||||
|
grid.appendChild(c);
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
for (let d = 1; d <= last.getDate(); d++) {
|
||||||
|
const cell = document.createElement("button");
|
||||||
|
cell.type = "button";
|
||||||
|
cell.className = "ios-month-cell";
|
||||||
|
const list = eventsForLocalDay(y, m, d);
|
||||||
|
if (list.length) cell.classList.add("ios-month-cell--has");
|
||||||
|
if (y === today.getFullYear() && m === today.getMonth() && d === today.getDate()) {
|
||||||
|
cell.classList.add("ios-month-cell--today");
|
||||||
|
}
|
||||||
|
cell.innerHTML = `<span class="ios-month-num">${d}</span>`;
|
||||||
|
if (list.length) {
|
||||||
|
const dots = document.createElement("div");
|
||||||
|
dots.className = "ios-month-dots";
|
||||||
|
list.slice(0, 4).forEach(() => {
|
||||||
|
const dot = document.createElement("span");
|
||||||
|
dot.className = "ios-month-dot";
|
||||||
|
dots.appendChild(dot);
|
||||||
|
});
|
||||||
|
cell.appendChild(dots);
|
||||||
|
}
|
||||||
|
cell.addEventListener("click", () => {
|
||||||
|
iosViewMode = "day";
|
||||||
|
iosCursor = new Date(y, m, d);
|
||||||
|
setActiveSegments();
|
||||||
|
updateToolbarTitle();
|
||||||
|
renderAgenda();
|
||||||
|
});
|
||||||
|
grid.appendChild(cell);
|
||||||
|
}
|
||||||
|
wrap.appendChild(grid);
|
||||||
|
iosAgendaMount.innerHTML = "";
|
||||||
|
iosAgendaMount.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgenda() {
|
||||||
|
updateToolbarTitle();
|
||||||
|
setActiveSegments();
|
||||||
|
if (iosViewMode === "month") {
|
||||||
|
renderMonthInAgenda();
|
||||||
|
} else {
|
||||||
|
renderWeekOrDay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNowTimer() {
|
||||||
|
if (iosNowTimer) clearInterval(iosNowTimer);
|
||||||
|
iosNowTimer = setInterval(() => {
|
||||||
|
if (iosViewMode === "week" || iosViewMode === "day") renderAgenda();
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showListSection(show) {
|
||||||
|
iosListVisible = !!show;
|
||||||
|
const sec = document.getElementById("ios-list-section");
|
||||||
|
if (!sec) return;
|
||||||
|
sec.style.display = show ? "block" : "none";
|
||||||
|
if (show && iosAgendaMount) {
|
||||||
|
iosAgendaMount.innerHTML =
|
||||||
|
'<p class="ios-agenda-list-hint">Vue liste ci-dessous. Utilise <strong>Jour</strong>, <strong>Semaine</strong> ou <strong>Mois</strong> pour retrouver l’agenda.</p>';
|
||||||
|
} else {
|
||||||
|
renderAgenda();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
const events = await iosApi("events");
|
||||||
|
iosEventsCache = events;
|
||||||
|
if (iosEventsList) {
|
||||||
|
iosEventsList.innerHTML = "";
|
||||||
|
if (!events.length) {
|
||||||
|
iosEventsList.innerHTML = "<div class='ios-agenda-intro'>Aucun événement.</div>";
|
||||||
|
} else {
|
||||||
|
events.forEach((evt) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "ios-event-card";
|
||||||
|
if (evt.display_color && /^#[0-9A-Fa-f]{6}$/i.test(evt.display_color)) {
|
||||||
|
card.style.borderLeft = `4px solid ${evt.display_color}`;
|
||||||
|
}
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="ios-event-card-head">
|
||||||
|
<div>
|
||||||
|
<div class="ios-event-card-title">${escHtml(evt.title || "(Sans titre)")}</div>
|
||||||
|
<div class="ios-event-card-meta">${escHtml(formatDate(evt.start_at))} → ${escHtml(formatDate(evt.end_at))}</div>
|
||||||
|
<div class="ios-event-card-meta">${escHtml(evt.location || "")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ios-event-card-actions">
|
||||||
|
<button type="button" class="ios-agenda-btn" data-edit="${evt.id}">Modifier</button>
|
||||||
|
<button type="button" class="ios-agenda-btn" data-delete="${evt.id}">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
card.querySelector("[data-edit]").addEventListener("click", () => fillForm(evt));
|
||||||
|
card.querySelector("[data-delete]").addEventListener("click", async () => {
|
||||||
|
if (!confirm("Supprimer cet événement ?")) return;
|
||||||
|
await iosApi("events", { method: "DELETE", body: { id: evt.id } });
|
||||||
|
await loadEvents();
|
||||||
|
});
|
||||||
|
iosEventsList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!iosListVisible) {
|
||||||
|
renderAgenda();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSyncStatus() {
|
||||||
|
if (!iosSyncStatus) return;
|
||||||
|
const status = await iosApi("sync_status");
|
||||||
|
iosSyncStatus.textContent = status.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCalendarPrefs() {
|
||||||
|
const wrap = document.getElementById("ios-calendar-prefs-rows");
|
||||||
|
const msg = document.getElementById("ios-calendar-prefs-msg");
|
||||||
|
if (!wrap) return;
|
||||||
|
if (msg) msg.textContent = "";
|
||||||
|
wrap.innerHTML = "<span class=\"ios-agenda-intro\">Chargement des calendriers…</span>";
|
||||||
|
try {
|
||||||
|
const data = await iosApi("calendar_sources");
|
||||||
|
const rows = data.calendars || [];
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
if (!rows.length) {
|
||||||
|
wrap.innerHTML =
|
||||||
|
"<span class=\"ios-agenda-intro\">Aucun calendrier détecté. Configure iCloud dans Paramètres puis synchronise.</span>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.forEach((row, idx) => {
|
||||||
|
const id = `ios-cal-cb-${idx}`;
|
||||||
|
const rowEl = document.createElement("div");
|
||||||
|
rowEl.className = "ios-cal-pref-row";
|
||||||
|
rowEl.dataset.urlKey = row.url_key;
|
||||||
|
|
||||||
|
const cb = document.createElement("input");
|
||||||
|
cb.type = "checkbox";
|
||||||
|
cb.id = id;
|
||||||
|
cb.checked = row.visible !== false;
|
||||||
|
|
||||||
|
const lab = document.createElement("label");
|
||||||
|
lab.className = "ios-cal-pref-label";
|
||||||
|
lab.htmlFor = id;
|
||||||
|
lab.textContent = row.display || row.url_key;
|
||||||
|
|
||||||
|
const colInp = document.createElement("input");
|
||||||
|
colInp.type = "color";
|
||||||
|
colInp.className = "ios-cal-pref-color";
|
||||||
|
colInp.value = row.color || suggestedColorForKey(row.url_key);
|
||||||
|
colInp.title = "Couleur dans l’agenda";
|
||||||
|
|
||||||
|
const autoBtn = document.createElement("button");
|
||||||
|
autoBtn.type = "button";
|
||||||
|
autoBtn.className = "ios-cal-pref-reset";
|
||||||
|
autoBtn.textContent = "Palette auto";
|
||||||
|
autoBtn.title = "Couleurs HouseHub par défaut (sans teinte iOS)";
|
||||||
|
autoBtn.addEventListener("click", () => {
|
||||||
|
rowEl.dataset.paletteAuto = "1";
|
||||||
|
colInp.disabled = true;
|
||||||
|
colInp.style.opacity = "0.35";
|
||||||
|
});
|
||||||
|
|
||||||
|
rowEl.appendChild(cb);
|
||||||
|
rowEl.appendChild(lab);
|
||||||
|
rowEl.appendChild(colInp);
|
||||||
|
rowEl.appendChild(autoBtn);
|
||||||
|
wrap.appendChild(rowEl);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
wrap.innerHTML = `<span class="ios-agenda-intro">${escHtml(e.message)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCalendarPrefs() {
|
||||||
|
const msg = document.getElementById("ios-calendar-prefs-msg");
|
||||||
|
const wrap = document.getElementById("ios-calendar-prefs-rows");
|
||||||
|
if (!wrap) return;
|
||||||
|
const prefs = {};
|
||||||
|
wrap.querySelectorAll(".ios-cal-pref-row").forEach((rowEl) => {
|
||||||
|
const key = rowEl.dataset.urlKey;
|
||||||
|
if (!key) return;
|
||||||
|
const vis = rowEl.querySelector('input[type="checkbox"]')?.checked === true;
|
||||||
|
const colInp = rowEl.querySelector(".ios-cal-pref-color");
|
||||||
|
let color = null;
|
||||||
|
if (!rowEl.dataset.paletteAuto && colInp && !colInp.disabled) {
|
||||||
|
const v = colInp.value;
|
||||||
|
if (v && /^#[0-9A-Fa-f]{6}$/i.test(v)) {
|
||||||
|
color = v.startsWith("#") ? v.toLowerCase() : `#${v}`.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs[key] = { visible: vis, color };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await iosApi("calendar_prefs", { method: "POST", body: { prefs } });
|
||||||
|
if (msg) msg.textContent = "Préférences enregistrées.";
|
||||||
|
await loadEvents();
|
||||||
|
await loadCalendarPrefs();
|
||||||
|
} catch (e) {
|
||||||
|
if (msg) msg.textContent = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
iosForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const idStr = document.getElementById("ios-event-id").value.trim();
|
||||||
|
const payload = {
|
||||||
|
title: document.getElementById("ios-title").value.trim(),
|
||||||
|
location: document.getElementById("ios-location").value.trim(),
|
||||||
|
description: document.getElementById("ios-description").value.trim(),
|
||||||
|
start_at: localDatetimeToSql(document.getElementById("ios-start").value),
|
||||||
|
end_at: localDatetimeToSql(document.getElementById("ios-end").value),
|
||||||
|
};
|
||||||
|
if (idStr) {
|
||||||
|
payload.id = parseInt(idStr, 10);
|
||||||
|
await iosApi("events", { method: "PUT", body: payload });
|
||||||
|
} else {
|
||||||
|
await iosApi("events", { method: "POST", body: payload });
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
await loadEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-form-reset")?.addEventListener("click", resetForm);
|
||||||
|
|
||||||
|
document.getElementById("ios-sync-btn")?.addEventListener("click", async () => {
|
||||||
|
const btn = document.getElementById("ios-sync-btn");
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const data = await iosApi("sync", { method: "POST", body: {} });
|
||||||
|
if (iosSyncStatus) iosSyncStatus.textContent = data.message;
|
||||||
|
await loadEvents();
|
||||||
|
await loadCalendarPrefs();
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".ios-seg").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
iosViewMode = /** @type {'day'|'week'|'month'} */ (btn.getAttribute("data-ios-view"));
|
||||||
|
showListSection(false);
|
||||||
|
startNowTimer();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-view-list")?.addEventListener("click", () => {
|
||||||
|
showListSection(true);
|
||||||
|
if (iosNowTimer) clearInterval(iosNowTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-agenda-prev")?.addEventListener("click", () => {
|
||||||
|
if (iosViewMode === "day") iosCursor = addDays(iosCursor, -1);
|
||||||
|
else if (iosViewMode === "week") iosCursor = addDays(iosCursor, -7);
|
||||||
|
else iosCursor = addMonths(iosCursor, -1);
|
||||||
|
renderAgenda();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-agenda-next")?.addEventListener("click", () => {
|
||||||
|
if (iosViewMode === "day") iosCursor = addDays(iosCursor, 1);
|
||||||
|
else if (iosViewMode === "week") iosCursor = addDays(iosCursor, 7);
|
||||||
|
else iosCursor = addMonths(iosCursor, 1);
|
||||||
|
renderAgenda();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-agenda-today")?.addEventListener("click", () => {
|
||||||
|
iosCursor = new Date();
|
||||||
|
showListSection(false);
|
||||||
|
startNowTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-agenda-add")?.addEventListener("click", () => {
|
||||||
|
resetForm();
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(Math.ceil(now.getMinutes() / 15) * 15, 0, 0);
|
||||||
|
const end = new Date(now.getTime() + 3600000);
|
||||||
|
const f = (d) => {
|
||||||
|
const p = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
};
|
||||||
|
document.getElementById("ios-start").value = f(now);
|
||||||
|
document.getElementById("ios-end").value = f(end);
|
||||||
|
document.getElementById("ios-edit-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
document.getElementById("ios-title")?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-calendar-prefs-save")?.addEventListener("click", () => saveCalendarPrefs());
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
try {
|
||||||
|
iosCursor = new Date();
|
||||||
|
await loadEvents();
|
||||||
|
await loadCalendarPrefs();
|
||||||
|
await loadSyncStatus();
|
||||||
|
showListSection(false);
|
||||||
|
startNowTimer();
|
||||||
|
} catch (e) {
|
||||||
|
if (iosSyncStatus) iosSyncStatus.textContent = e.message;
|
||||||
|
if (iosAgendaMount) iosAgendaMount.innerHTML = `<p class="ios-agenda-intro">${escHtml(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<?php
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/crypto.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/icloud_caldav.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mot de passe normalisé + URL calendrier (découverte si racine iCloud).
|
||||||
|
*
|
||||||
|
* @return array{integration: array, password: string}
|
||||||
|
*/
|
||||||
|
function ios_caldav_prepare(array $integration, ?PDO $metaPdo = null): array
|
||||||
|
{
|
||||||
|
$password = hh_normalize_apple_app_password(hh_decrypt_secret($integration['secret_encrypted']));
|
||||||
|
$url = trim($integration['calendar_url'] ?? '');
|
||||||
|
$resolvedUrl = hh_icloud_resolve_calendar_url_if_needed($integration['username'], $password, $url);
|
||||||
|
$out = $integration;
|
||||||
|
$out['calendar_url'] = $resolvedUrl;
|
||||||
|
if ($metaPdo && !empty($integration['id']) && rtrim($url, '/') !== rtrim($resolvedUrl, '/')) {
|
||||||
|
$metaPdo->prepare('UPDATE user_calendar_integrations SET calendar_url = ? WHERE id = ?')
|
||||||
|
->execute([$resolvedUrl, $integration['id']]);
|
||||||
|
}
|
||||||
|
return ['integration' => $out, 'password' => $password];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_make_ics(array $event): string
|
||||||
|
{
|
||||||
|
$uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');
|
||||||
|
$start = gmdate('Ymd\THis\Z', strtotime($event['start_at']));
|
||||||
|
$end = gmdate('Ymd\THis\Z', strtotime($event['end_at']));
|
||||||
|
$summary = addcslashes($event['title'] ?? '', ",;");
|
||||||
|
$description = addcslashes($event['description'] ?? '', ",;");
|
||||||
|
$location = addcslashes($event['location'] ?? '', ",;");
|
||||||
|
$updated = gmdate('Ymd\THis\Z');
|
||||||
|
|
||||||
|
return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//HouseHub//Calendar iOS//FR\r\nBEGIN:VEVENT\r\nUID:$uid\r\nDTSTAMP:$updated\r\nDTSTART:$start\r\nDTEND:$end\r\nSUMMARY:$summary\r\nDESCRIPTION:$description\r\nLOCATION:$location\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_caldav_request(string $url, string $username, string $password, string $method = 'GET', ?string $body = null, array $headers = []): array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$defaultCt = ($method === 'REPORT' || $method === 'PROPFIND')
|
||||||
|
? 'application/xml; charset=utf-8'
|
||||||
|
: 'text/calendar; charset=utf-8';
|
||||||
|
$requestHeaders = array_merge([
|
||||||
|
'Content-Type: ' . $defaultCt,
|
||||||
|
], $headers);
|
||||||
|
$timeout = ($method === 'REPORT' || $method === 'PROPFIND') ? 60 : 20;
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => $timeout,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
|
||||||
|
CURLOPT_USERPWD => $username . ':' . $password,
|
||||||
|
CURLOPT_HTTPHEADER => $requestHeaders,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 10,
|
||||||
|
]);
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($raw === false) {
|
||||||
|
return ['code' => 0, 'headers' => '', 'body' => ''];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'code' => $code,
|
||||||
|
'headers' => substr($raw, 0, $headerSize),
|
||||||
|
'body' => substr($raw, $headerSize),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déplie les lignes ICS pliées (RFC 5545).
|
||||||
|
*/
|
||||||
|
function ios_ics_unfold(string $ics): string
|
||||||
|
{
|
||||||
|
$ics = str_replace("\r\n", "\n", $ics);
|
||||||
|
return preg_replace("/\n[ \t]/", '', $ics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{external_uid:string,title:string,description:string,location:string,start_at:string,end_at:string}>
|
||||||
|
*/
|
||||||
|
function ios_parse_vevents_from_ics(string $ics): array
|
||||||
|
{
|
||||||
|
$ics = ios_ics_unfold($ics);
|
||||||
|
$events = [];
|
||||||
|
if (!preg_match_all('/BEGIN:VEVENT\s*(.*?)\s*END:VEVENT/s', $ics, $matches)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
foreach ($matches[1] as $chunk) {
|
||||||
|
if (!preg_match('/^UID:([^\r\n]+)/m', $chunk, $uidM)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$uid = trim($uidM[1]);
|
||||||
|
$summary = '';
|
||||||
|
if (preg_match('/^SUMMARY:([^\r\n]*)/m', $chunk, $m)) {
|
||||||
|
$summary = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1])));
|
||||||
|
}
|
||||||
|
$description = '';
|
||||||
|
if (preg_match('/^DESCRIPTION:([^\r\n]*)/m', $chunk, $m)) {
|
||||||
|
$description = str_replace('\\,', ',', str_replace('\\;', ';', str_replace('\\n', "\n", trim($m[1]))));
|
||||||
|
}
|
||||||
|
$location = '';
|
||||||
|
if (preg_match('/^LOCATION:([^\r\n]*)/m', $chunk, $m)) {
|
||||||
|
$location = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1])));
|
||||||
|
}
|
||||||
|
$startRaw = null;
|
||||||
|
if (preg_match('/^DTSTART[^:]*:([^\r\n]+)/m', $chunk, $m)) {
|
||||||
|
$startRaw = trim($m[1]);
|
||||||
|
}
|
||||||
|
if ($startRaw === null || $startRaw === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$endRaw = null;
|
||||||
|
if (preg_match('/^DTEND[^:]*:([^\r\n]+)/m', $chunk, $m)) {
|
||||||
|
$endRaw = trim($m[1]);
|
||||||
|
}
|
||||||
|
if (preg_match('/^\d{8}$/', $startRaw)) {
|
||||||
|
$startTs = strtotime($startRaw . 'T000000 UTC');
|
||||||
|
} else {
|
||||||
|
$norm = preg_replace('/^(\d{8})T(\d{6})Z?$/', '$1T$2 UTC', $startRaw);
|
||||||
|
$startTs = strtotime($norm ?: $startRaw);
|
||||||
|
if ($startTs === false) {
|
||||||
|
$startTs = strtotime($startRaw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($startTs === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($endRaw !== null && $endRaw !== '') {
|
||||||
|
if (preg_match('/^\d{8}$/', $endRaw)) {
|
||||||
|
$endTs = strtotime($endRaw . 'T000000 UTC');
|
||||||
|
} else {
|
||||||
|
$normE = preg_replace('/^(\d{8})T(\d{6})Z?$/', '$1T$2 UTC', $endRaw);
|
||||||
|
$endTs = strtotime($normE ?: $endRaw);
|
||||||
|
if ($endTs === false) {
|
||||||
|
$endTs = strtotime($endRaw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$endTs = $startTs;
|
||||||
|
}
|
||||||
|
if ($endTs === false) {
|
||||||
|
$endTs = $startTs;
|
||||||
|
}
|
||||||
|
$events[] = [
|
||||||
|
'external_uid' => $uid,
|
||||||
|
'title' => $summary,
|
||||||
|
'description' => $description,
|
||||||
|
'location' => $location,
|
||||||
|
'start_at' => date('Y-m-d H:i:s', $startTs),
|
||||||
|
'end_at' => date('Y-m-d H:i:s', $endTs),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associe chaque fragment iCalendar du REPORT à l’URL de ressource CalDAV (nécessaire pour PUT/DELETE sur iCloud).
|
||||||
|
*
|
||||||
|
* @return list<array{resource_url:string,ical:string}>
|
||||||
|
*/
|
||||||
|
function ios_caldav_parse_report_calendar_fragments(string $xmlBody, string $calendarBaseUrl): array
|
||||||
|
{
|
||||||
|
$dom = new DOMDocument();
|
||||||
|
if (!@$dom->loadXML($xmlBody)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$xpath = new DOMXPath($dom);
|
||||||
|
$responses = $xpath->query("//*[local-name()='response']");
|
||||||
|
$out = [];
|
||||||
|
foreach ($responses as $resp) {
|
||||||
|
$hrefNodes = $xpath->query(".//*[local-name()='href']", $resp);
|
||||||
|
if ($hrefNodes->length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$href = trim($hrefNodes->item(0)->textContent);
|
||||||
|
if ($href === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cd = $xpath->query(".//*[local-name()='calendar-data']", $resp);
|
||||||
|
if ($cd->length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ical = trim($cd->item(0)->textContent);
|
||||||
|
if ($ical === '' || stripos($ical, 'BEGIN:VEVENT') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$resourceUrl = (str_starts_with($href, 'http://') || str_starts_with($href, 'https://'))
|
||||||
|
? $href
|
||||||
|
: hh_caldav_resolve_href(rtrim($calendarBaseUrl, '/') . '/', $href);
|
||||||
|
$out[] = ['resource_url' => $resourceUrl, 'ical' => $ical];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URLs des collections calendrier à interroger (tous les calendriers iCloud du compte, sinon l’URL configurée seule).
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function ios_sync_calendar_collection_urls(array $integration, string $password): array
|
||||||
|
{
|
||||||
|
$primary = rtrim($integration['calendar_url'] ?? '', '/') . '/';
|
||||||
|
if (hh_caldav_url_is_icloud($primary)) {
|
||||||
|
try {
|
||||||
|
$entries = hh_icloud_discover_calendar_entries($integration['username'], $password);
|
||||||
|
|
||||||
|
return array_values(array_unique(array_map(static function (array $e): string {
|
||||||
|
return rtrim($e['url'], '/') . '/';
|
||||||
|
}, $entries)));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// compte partiellement lisible : au moins le calendrier principal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [$primary];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string,mixed>> champs événement + _resource_url + _calendar_collection_url
|
||||||
|
*/
|
||||||
|
function ios_fetch_remote_events(array $integration, string $password): array
|
||||||
|
{
|
||||||
|
$calUrl = rtrim($integration['calendar_url'], '/') . '/';
|
||||||
|
$tStart = gmdate('Ymd\THis\Z', strtotime('-3 years'));
|
||||||
|
$tEnd = gmdate('Ymd\THis\Z', strtotime('+4 years'));
|
||||||
|
$reportXml = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">'
|
||||||
|
. '<D:prop><C:calendar-data/></D:prop>'
|
||||||
|
. '<C:filter>'
|
||||||
|
. '<C:comp-filter name="VCALENDAR">'
|
||||||
|
. '<C:comp-filter name="VEVENT">'
|
||||||
|
. '<C:time-range start="' . htmlspecialchars($tStart, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '" end="' . htmlspecialchars($tEnd, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '"/>'
|
||||||
|
. '</C:comp-filter></C:comp-filter></C:filter>'
|
||||||
|
. '</C:calendar-query>';
|
||||||
|
|
||||||
|
$res = ios_caldav_request($calUrl, $integration['username'], $password, 'REPORT', $reportXml, [
|
||||||
|
'Accept: application/xml, text/xml',
|
||||||
|
'Depth: 1',
|
||||||
|
]);
|
||||||
|
if (!in_array($res['code'], [200, 207], true)) {
|
||||||
|
throw new RuntimeException('Lecture CalDAV (REPORT) impossible (HTTP ' . $res['code'] . ').');
|
||||||
|
}
|
||||||
|
$fragments = ios_caldav_parse_report_calendar_fragments($res['body'], $calUrl);
|
||||||
|
$byUid = [];
|
||||||
|
foreach ($fragments as $frag) {
|
||||||
|
foreach (ios_parse_vevents_from_ics($frag['ical']) as $ev) {
|
||||||
|
$ev['_resource_url'] = $frag['resource_url'];
|
||||||
|
$ev['_calendar_collection_url'] = $calUrl;
|
||||||
|
$byUid[$ev['external_uid']] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($byUid === [] && strpos($res['body'], 'multistatus') === false) {
|
||||||
|
$get = ios_caldav_request($calUrl, $integration['username'], $password, 'GET', null, ['Accept: text/calendar']);
|
||||||
|
if ($get['code'] >= 200 && $get['code'] < 400 && str_contains($get['body'], 'BEGIN:VEVENT')) {
|
||||||
|
foreach (ios_parse_vevents_from_ics($get['body']) as $ev) {
|
||||||
|
$ev['_resource_url'] = null;
|
||||||
|
$ev['_calendar_collection_url'] = $calUrl;
|
||||||
|
$byUid[$ev['external_uid']] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values($byUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fusionne tous les calendriers du compte (iCloud) ou une seule collection.
|
||||||
|
*
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
function ios_fetch_remote_events_all_calendars(array $integration, string $password): array
|
||||||
|
{
|
||||||
|
$urls = ios_sync_calendar_collection_urls($integration, $password);
|
||||||
|
$merged = [];
|
||||||
|
foreach ($urls as $url) {
|
||||||
|
$sub = $integration;
|
||||||
|
$sub['calendar_url'] = $url;
|
||||||
|
foreach (ios_fetch_remote_events($sub, $password) as $ev) {
|
||||||
|
$merged[$ev['external_uid']] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values($merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_push_event_to_remote(array $integration, array $event, string $password, ?string $resourceUrl = null, ?string $collectionUrlOverride = null): array
|
||||||
|
{
|
||||||
|
$uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');
|
||||||
|
$collection = rtrim($collectionUrlOverride ?? $integration['calendar_url'], '/');
|
||||||
|
$url = $resourceUrl ?: ($collection . '/' . rawurlencode($uid) . '.ics');
|
||||||
|
$ics = ios_make_ics($event);
|
||||||
|
return ios_caldav_request($url, $integration['username'], $password, 'PUT', $ics);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_delete_remote_event(array $integration, string $externalUid, string $password, ?string $resourceUrl = null, ?string $collectionUrlOverride = null): array
|
||||||
|
{
|
||||||
|
$collection = rtrim($collectionUrlOverride ?? $integration['calendar_url'], '/');
|
||||||
|
$url = $resourceUrl ?: ($collection . '/' . rawurlencode($externalUid) . '.ics');
|
||||||
|
return ios_caldav_request($url, $integration['username'], $password, 'DELETE', null, ['Accept: */*']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
// /modules/family-calendar/admin/generate-calendar-year.php
|
||||||
|
require __DIR__ . '/../../../includes/db.php';
|
||||||
|
|
||||||
|
// S'assurer que PDO lève des exceptions en cas d'erreur
|
||||||
|
if (isset($pdo) && $pdo instanceof PDO) {
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
} else {
|
||||||
|
die("Erreur : PDO non initialisé dans db.php");
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = isset($_GET['year']) ? (int)$_GET['year'] : (int)date('Y');
|
||||||
|
if ($year < 2000 || $year > 2100) {
|
||||||
|
die("Année invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
// On supprime d'abord les semaines de cette année calendaire
|
||||||
|
$stmtDel = $pdo->prepare("DELETE FROM pf_calendar_weeks WHERE year = :year");
|
||||||
|
$stmtDel->execute([':year' => $year]);
|
||||||
|
|
||||||
|
// 1er janvier de l'année calendaire
|
||||||
|
$start = new DateTime("$year-01-01");
|
||||||
|
|
||||||
|
// On remonte au lundi précédent (ou on reste dessus si déjà lundi)
|
||||||
|
while ($start->format('N') != 1) { // 1 = lundi
|
||||||
|
$start->modify('-1 day');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dernier jour de l'année
|
||||||
|
$end = new DateTime("$year-12-31");
|
||||||
|
|
||||||
|
// Préparation de l'INSERT avec gestion des doublons
|
||||||
|
// (en supposant une contrainte UNIQUE, par ex. sur (year, week_iso_year, week_iso_number))
|
||||||
|
$sql = "
|
||||||
|
INSERT INTO pf_calendar_weeks (
|
||||||
|
year, week_iso_year, week_iso_number, week_label,
|
||||||
|
month, month_name,
|
||||||
|
week_start_date, mon_date, tue_date, wed_date, thu_date, fri_date, sat_date, sun_date
|
||||||
|
) VALUES (
|
||||||
|
:year, :week_iso_year, :week_iso_number, :week_label,
|
||||||
|
:month, :month_name,
|
||||||
|
:week_start_date, :mon_date, :tue_date, :wed_date, :thu_date, :fri_date, :sat_date, :sun_date
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
week_label = VALUES(week_label),
|
||||||
|
month = VALUES(month),
|
||||||
|
month_name = VALUES(month_name),
|
||||||
|
week_start_date = VALUES(week_start_date),
|
||||||
|
mon_date = VALUES(mon_date),
|
||||||
|
tue_date = VALUES(tue_date),
|
||||||
|
wed_date = VALUES(wed_date),
|
||||||
|
thu_date = VALUES(thu_date),
|
||||||
|
fri_date = VALUES(fri_date),
|
||||||
|
sat_date = VALUES(sat_date),
|
||||||
|
sun_date = VALUES(sun_date)
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
// fonction mois FR
|
||||||
|
function getMonthNameFr($monthIndexZeroBased) {
|
||||||
|
$months = [
|
||||||
|
"Janvier", "Fevrier", "Mars", "Avril", "Mai", "Juin",
|
||||||
|
"Juillet", "Aout", "Septembre", "Octobre", "Novembre", "Decembre",
|
||||||
|
];
|
||||||
|
return $months[$monthIndexZeroBased] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = clone $start;
|
||||||
|
$insertCount = 0;
|
||||||
|
|
||||||
|
while ($current <= $end) {
|
||||||
|
$monday = clone $current;
|
||||||
|
|
||||||
|
// Calcul des 7 jours de la semaine
|
||||||
|
$mon = clone $monday;
|
||||||
|
$tue = (clone $monday)->modify('+1 day');
|
||||||
|
$wed = (clone $monday)->modify('+2 days');
|
||||||
|
$thu = (clone $monday)->modify('+3 days');
|
||||||
|
$fri = (clone $monday)->modify('+4 days');
|
||||||
|
$sat = (clone $monday)->modify('+5 days');
|
||||||
|
$sun = (clone $monday)->modify('+6 days');
|
||||||
|
|
||||||
|
// Année / semaine ISO
|
||||||
|
$weekIsoYear = (int)$monday->format('o'); // année ISO
|
||||||
|
$weekIsoNumber = (int)$monday->format('W');
|
||||||
|
$weekLabel = 'W' . str_pad($weekIsoNumber, 2, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
// Détermination du mois d'affectation de la semaine
|
||||||
|
// -> on compte combien de jours de la semaine tombent dans chaque mois
|
||||||
|
$days = [$mon, $tue, $wed, $thu, $fri, $sat, $sun];
|
||||||
|
|
||||||
|
// compteur mois => nb de jours (clé = numéro de mois 1..12)
|
||||||
|
$monthCounts = [];
|
||||||
|
|
||||||
|
foreach ($days as $d) {
|
||||||
|
$m = (int)$d->format('n'); // 1-12
|
||||||
|
if (!isset($monthCounts[$m])) {
|
||||||
|
$monthCounts[$m] = 0;
|
||||||
|
}
|
||||||
|
$monthCounts[$m]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On prend le mois ayant le plus de jours
|
||||||
|
// (si égalité, le mois ayant la plus petite valeur numérique gagnera,
|
||||||
|
// ce qui est raisonnable, mais on peut changer si besoin)
|
||||||
|
$chosenMonth = null;
|
||||||
|
$maxDays = -1;
|
||||||
|
foreach ($monthCounts as $m => $count) {
|
||||||
|
if ($count > $maxDays) {
|
||||||
|
$maxDays = $count;
|
||||||
|
$chosenMonth = $m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$month = $chosenMonth;
|
||||||
|
$monthName = getMonthNameFr($month - 1);
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':year' => $weekIsoYear, // <-- au lieu de $year
|
||||||
|
':week_iso_year' => $weekIsoYear,
|
||||||
|
':week_iso_number' => $weekIsoNumber,
|
||||||
|
':week_label' => $weekLabel,
|
||||||
|
':month' => $month,
|
||||||
|
':month_name' => $monthName,
|
||||||
|
':week_start_date' => $monday->format('Y-m-d'),
|
||||||
|
':mon_date' => $mon->format('Y-m-d'),
|
||||||
|
':tue_date' => $tue->format('Y-m-d'),
|
||||||
|
':wed_date' => $wed->format('Y-m-d'),
|
||||||
|
':thu_date' => $thu->format('Y-m-d'),
|
||||||
|
':fri_date' => $fri->format('Y-m-d'),
|
||||||
|
':sat_date' => $sat->format('Y-m-d'),
|
||||||
|
':sun_date' => $sun->format('Y-m-d'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
$insertCount++;
|
||||||
|
$current->modify('+7 days');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
echo "Calendrier $year généré. Semaines traitées : " . $insertCount;
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
die("Erreur PDO : " . $e->getMessage());
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
die("Erreur générale : " . $e->getMessage());
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?xml version="1.0" ?><svg id="Layer_1" style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||||
|
.st0{fill:#FFFFFF;}
|
||||||
|
.st1{fill:#C60C30;}
|
||||||
|
.st2{fill:#003478;}
|
||||||
|
.st3{fill:#FF9933;}
|
||||||
|
.st4{fill:#128807;}
|
||||||
|
.st5{fill:#000088;}
|
||||||
|
.st6{fill:#032774;}
|
||||||
|
.st7{fill:#DF0333;}
|
||||||
|
.st8{fill:#EE2B2C;}
|
||||||
|
.st9{fill:#032767;}
|
||||||
|
.st10{fill:#006747;}
|
||||||
|
.st11{fill:#CD1225;}
|
||||||
|
.st12{fill:#FBC942;}
|
||||||
|
.st13{fill:#BD9B37;}
|
||||||
|
.st14{fill:#9BA068;}
|
||||||
|
.st15{fill:#707633;}
|
||||||
|
.st16{fill:#006748;}
|
||||||
|
.st17{fill:#CC2029;}
|
||||||
|
.st18{fill:#30C1DB;}
|
||||||
|
.st19{fill:#F7C740;}
|
||||||
|
.st20{fill:#0C72A6;}
|
||||||
|
.st21{fill:#F8A952;}
|
||||||
|
.st22{fill:#943120;}
|
||||||
|
.st23{fill:#231F20;}
|
||||||
|
.st24{fill:#0B8388;}
|
||||||
|
.st25{fill:#8BBDBE;}
|
||||||
|
.st26{fill:#05534E;}
|
||||||
|
.st27{fill:none;stroke:#39B54A;stroke-width:0.25;stroke-miterlimit:10;}
|
||||||
|
.st28{fill:none;stroke:#00A651;stroke-miterlimit:10;}
|
||||||
|
.st29{fill:none;stroke:#00A651;stroke-width:0.5;stroke-miterlimit:10;}
|
||||||
|
.st30{fill:url(#SVGID_1_);}
|
||||||
|
.st31{fill:url(#SVGID_2_);}
|
||||||
|
.st32{fill:url(#SVGID_3_);}
|
||||||
|
.st33{fill:url(#SVGID_4_);}
|
||||||
|
.st34{fill:url(#SVGID_5_);}
|
||||||
|
.st35{fill:url(#SVGID_6_);}
|
||||||
|
.st36{fill:url(#SVGID_7_);}
|
||||||
|
.st37{fill:url(#SVGID_8_);}
|
||||||
|
.st38{fill:url(#SVGID_9_);}
|
||||||
|
.st39{fill:url(#SVGID_10_);}
|
||||||
|
.st40{fill:url(#SVGID_11_);}
|
||||||
|
.st41{fill:url(#SVGID_12_);}
|
||||||
|
.st42{fill:url(#SVGID_13_);}
|
||||||
|
.st43{fill:url(#SVGID_14_);}
|
||||||
|
.st44{fill:url(#SVGID_15_);}
|
||||||
|
.st45{fill:#F8C73E;}
|
||||||
|
.st46{fill:#77722F;}
|
||||||
|
.st47{fill:#A7AB71;}
|
||||||
|
.st48{fill:#949457;}
|
||||||
|
.st49{fill:#AE6F2A;}
|
||||||
|
.st50{fill:#FFF200;}
|
||||||
|
.st51{fill:#C5C6A6;}
|
||||||
|
.st52{fill:#A3A395;}
|
||||||
|
.st53{fill:#4C2915;}
|
||||||
|
.st54{fill:#8E4520;}
|
||||||
|
.st55{fill:#D1A467;}
|
||||||
|
.st56{fill:#AA6C2A;}
|
||||||
|
.st57{fill:#DAAC6D;}
|
||||||
|
.st58{fill:#8F4620;}
|
||||||
|
.st59{fill:#BC7E2F;}
|
||||||
|
.st60{fill:#E6E7E8;}
|
||||||
|
.st61{fill:#F06D19;}
|
||||||
|
.st62{fill:#19B43D;}
|
||||||
|
.st63{fill:#07A3DC;}
|
||||||
|
.st64{fill:#FBD023;}
|
||||||
|
.st65{fill:#EC2938;}
|
||||||
|
.st66{fill:#F9DF47;}
|
||||||
|
.st67{fill:#0967C4;}
|
||||||
|
.st68{fill:#C5A310;}
|
||||||
|
.st69{clip-path:url(#SVGID_17_);}
|
||||||
|
.st70{fill:#6FD7F2;}
|
||||||
|
.st71{clip-path:url(#SVGID_19_);}
|
||||||
|
.st72{fill:#FF0303;}
|
||||||
|
.st73{fill:#FF6407;}
|
||||||
|
.st74{fill:#FDFE1F;}
|
||||||
|
.st75{fill:#00FE1E;}
|
||||||
|
.st76{fill:#08CCFE;}
|
||||||
|
.st77{fill:#120FFD;}
|
||||||
|
.st78{fill:#810480;}
|
||||||
|
.st79{fill:#0642A1;}
|
||||||
|
.st80{fill:#02275F;}
|
||||||
|
.st81{fill:#80A0D1;}
|
||||||
|
.st82{fill:#FB3B3A;}
|
||||||
|
.st83{fill:#FF0302;}
|
||||||
|
.st84{fill:#520200;}
|
||||||
|
.st85{fill:#940101;}
|
||||||
|
.st86{clip-path:url(#SVGID_21_);}
|
||||||
|
.st87{fill:#91BB35;}
|
||||||
|
.st88{fill:#ECE62B;}
|
||||||
|
.st89{fill:#C5CA2C;}
|
||||||
|
.st90{fill:#95C82B;}
|
||||||
|
.st91{fill:#009D4A;}
|
||||||
|
.st92{fill:#BF0A30;}
|
||||||
|
.st93{fill:#002868;}
|
||||||
|
.st94{fill:#028832;}
|
||||||
|
.st95{fill:#FBDC1D;}
|
||||||
|
.st96{fill:#D91219;}
|
||||||
|
.st97{fill:#0738A6;}
|
||||||
|
.st98{fill:#FD0302;}
|
||||||
|
.st99{fill:#060593;}
|
||||||
|
.st100{fill:#115EAD;}
|
||||||
|
.st101{fill:#C50C1E;}
|
||||||
|
.st102{fill:#FEC315;}
|
||||||
|
.st103{fill:#C7B011;}
|
||||||
|
.st104{fill:#AC1518;}
|
||||||
|
.st105{fill:#028E6E;}
|
||||||
|
.st106{fill:#CBCBCB;}
|
||||||
|
.st107{fill:#095BBD;}
|
||||||
|
.st108{fill:#C7B013;}
|
||||||
|
.st109{fill:#00AEEF;}
|
||||||
|
.st110{fill:#C7B112;}
|
||||||
|
.st111{fill:#FED592;}
|
||||||
|
.st112{fill:#028D6E;}
|
||||||
|
.st113{fill:#C7B012;}
|
||||||
|
.st114{fill:#C6B011;}
|
||||||
|
.st115{fill:#103BEE;}
|
||||||
|
.st116{fill:#DB4445;}
|
||||||
|
.st117{fill:#EC72A9;}
|
||||||
|
.st118{fill:#006133;}
|
||||||
|
.st119{fill:#D11133;}
|
||||||
|
.st120{fill:#042B7D;}
|
||||||
|
.st121{fill:#FEC527;}
|
||||||
|
.st122{fill:#BF920D;}
|
||||||
|
</style><g><path class="st96" d="M510.4,284.4c-0.3,2.6-0.7,5.2-1,7.8c-0.2,1-0.3,2.1-0.5,3.2c-0.5,3.2-1.1,6.4-1.7,9.7 c-0.1,0.5-0.2,1-0.3,1.5v0.1c-0.1,0.5-0.2,1-0.2,1c-2.5,11.9-5.6,22.9-9.4,33.7H14.6c-3.4-9.8-6.3-19.8-8.6-30.1 c-0.2-0.5-0.3-1-0.4-1.5c-0.1-0.5-0.2-1-0.3-1.4c-1.6-7.9-2.9-15.8-3.7-23.9H510.4z"/><path class="st95" d="M497.4,341.3c-5.3,14.9-11.9,29.1-19.7,42.6c-2.8,4.9-5.8,9.6-8.9,14.3H43.1c-3.1-4.7-6-9.4-8.8-14.3 c-7.8-13.4-14.4-27.8-19.7-42.6H497.4z"/><path class="st96" d="M468.8,398.2c-4.1,6.1-8.4,12.1-13,17.8c-11.5,14.4-24.5,27.5-38.8,39.1H95c-14.3-11.6-27.4-24.7-38.8-39.1 c-4.6-5.7-9-11.7-13.1-17.8H468.8z"/><path class="st95" d="M417,455.1C372.9,490.7,317,512,256,512c-61,0-117-21.3-161-56.9H417z"/><path class="st96" d="M468.8,113.8H43.2c4.1-6.1,8.4-12.1,13-17.8c11.6-14.4,24.7-27.6,39-39.1h321.7c14.4,11.6,27.4,24.7,39,39.1 C460.4,101.7,464.8,107.6,468.8,113.8z"/><path class="st95" d="M416.8,56.9H95.1C139.1,21.3,195,0,256,0C316.9,0,372.9,21.3,416.8,56.9z"/><path class="st95" d="M497.4,170.7H14.6c5.2-14.9,11.8-29.2,19.7-42.7c2.8-4.8,5.7-9.6,8.9-14.2h425.7c3.1,4.6,6.1,9.4,8.9,14.2 C485.5,141.5,492.1,155.7,497.4,170.7z"/><path class="st96" d="M510.4,227.6H1.6c0,0,0.1-0.3,0.1-0.5V227l0.1-0.6c2.2-19.3,6.6-37.9,12.9-55.7h482.8 c3.2,8.8,5.7,17.9,7.9,27.1c0.1,0.2,0.1,0.5,0.2,0.7c0,0,0.1,0.5,0.2,0.7c1.3,5.9,2.4,11.8,3.4,17.8c0,0,0,0.4,0.1,0.6 c0,0,0.1,0.4,0.1,0.6C509.6,221.3,510.1,224.4,510.4,227.6z"/><path class="st95" d="M512,256c0,1.3,0,2.5-0.1,3.8c0,0.3,0,0.6,0,0.8v0.9l-0.1,0.3c0,0,0,0.4,0,0.6c-0.2,7.4-0.7,14.8-1.4,22.1 H1.6c-0.2-1.6-0.3-3-0.5-4.5c-0.1-0.9-0.2-1.7-0.2-2.5l0,0c-0.5-4.9-0.7-10-0.9-15.1c0-0.2,0-0.4,0-0.4c0-0.4,0-0.6,0-0.6 C0,260,0,258.8,0,257.5c0,0,0-0.3,0-0.6V256c0-1.7,0-3.5,0.1-5.2c0-0.2,0-0.3,0-0.3c0-0.4,0-0.6,0-0.6c0.1-5.3,0.4-10.5,0.9-15.6 c0-0.2,0.1-0.5,0.1-0.5c0-0.5,0.1-0.7,0.1-0.9c0.2-1.7,0.3-3.4,0.5-5.1v-0.2h508.8c0.8,7.2,1.3,14.6,1.5,22.1c0,0,0.1,0.4,0,0.6 c0,0,0.1,0.4,0.1,0.6C511.9,252.5,512,254.3,512,256z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$action = $_REQUEST['action'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ─── 1. RECUPÉRATION COMPLÈTE DES PARAMÈTRES (INITIALISATION) ───
|
||||||
|
if ($action === 'get_all') {
|
||||||
|
$stmtFoyer = $pdo->query("SELECT zone_scolaire, care_modes FROM pf_foyer_settings LIMIT 1");
|
||||||
|
$foyer = $stmtFoyer->fetch(PDO::FETCH_ASSOC) ?: ['zone_scolaire' => 'C', 'care_modes' => '[]'];
|
||||||
|
|
||||||
|
$stmtPeople = $pdo->query("SELECT id, name, role, care_modes, color FROM pf_people WHERE is_active = 1 ORDER BY role ASC, name ASC");
|
||||||
|
$people = $stmtPeople->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$stmtLeaves = $pdo->query("SELECT person_id, leave_type, anniversary_date, method, allowance FROM pf_person_leave_meta");
|
||||||
|
$leavesRaw = $stmtLeaves->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$leavesMap = [];
|
||||||
|
foreach ($leavesRaw as $leave) {
|
||||||
|
$leavesMap[$leave['person_id']][] = [
|
||||||
|
'type' => $leave['leave_type'],
|
||||||
|
'method' => $leave['method'] ?? 'FIXED',
|
||||||
|
'allowance' => $leave['allowance'] ?? 0,
|
||||||
|
'date' => $leave['anniversary_date']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmtSettings = $pdo->query("SELECT setting_key, setting_value FROM pf_settings WHERE module = 'calendar'");
|
||||||
|
$calendarSettingsRaw = $stmtSettings->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
$calendarSettings = [
|
||||||
|
'calendar_default_view' => $calendarSettingsRaw['calendar_default_view'] ?? 'month',
|
||||||
|
'calendar_first_day' => $calendarSettingsRaw['calendar_first_day'] ?? '1',
|
||||||
|
'calendar_working_hours' => $calendarSettingsRaw['calendar_working_hours'] ?? '08:00-19:00'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Catalogue des congés
|
||||||
|
$stmtLeaveTypes = $pdo->query("SELECT code, label, default_allowance, reset_month, allow_carry_over FROM pf_leave_types ORDER BY label ASC");
|
||||||
|
$leaveTypes = $stmtLeaveTypes->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'foyer' => [
|
||||||
|
'zone_scolaire' => $foyer['zone_scolaire'],
|
||||||
|
'care_modes' => json_decode($foyer['care_modes'] ?? '[]', true)
|
||||||
|
],
|
||||||
|
'people' => $people,
|
||||||
|
'leaves' => $leavesMap,
|
||||||
|
'calendar_settings' => $calendarSettings,
|
||||||
|
'leave_types' => $leaveTypes
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. SAUVEGARDE DU FOYER (ONGLET 1) ───
|
||||||
|
if ($action === 'save_foyer') {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
|
||||||
|
|
||||||
|
$zone = trim($_POST['zone_scolaire'] ?? 'C');
|
||||||
|
$modesRaw = $_POST['care_modes'] ?? '[]';
|
||||||
|
$modesArray = json_encode(array_values(array_unique(array_filter(json_decode($modesRaw, true)))));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_foyer_settings SET zone_scolaire = ?, care_modes = ? WHERE id = 1");
|
||||||
|
$stmt->execute([$zone, $modesArray]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 3. SAUVEGARDE DES MODES DE GARDE D'UN ENFANT ───
|
||||||
|
if ($action === 'save_child_care_modes') {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
|
||||||
|
|
||||||
|
$person_id = (int)($_POST['person_id'] ?? 0);
|
||||||
|
$care_modes = $_POST['care_modes'] ?? '[]';
|
||||||
|
|
||||||
|
if ($person_id > 0) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_people SET care_modes = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$care_modes, $person_id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ID de l\'enfant manquant.']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 4. GESTION DU CATALOGUE DE CONGÉS (FOYER) ───
|
||||||
|
if ($action === 'get_leave_types') {
|
||||||
|
$stmt = $pdo->query("SELECT * FROM pf_leave_types ORDER BY label ASC");
|
||||||
|
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'save_leave_type') {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
|
||||||
|
|
||||||
|
$mode = $_POST['mode'] ?? 'add';
|
||||||
|
$code = strtoupper(trim($_POST['code'] ?? ''));
|
||||||
|
$label = trim($_POST['label'] ?? '');
|
||||||
|
|
||||||
|
if (empty($code) || empty($label)) throw new Exception("Le code et le label sont obligatoires.");
|
||||||
|
|
||||||
|
if ($mode === 'add') {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_leave_types (code, label) VALUES (?, ?)");
|
||||||
|
$stmt->execute([$code, $label]);
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_leave_types SET label = ? WHERE code = ?");
|
||||||
|
$stmt->execute([$label, $code]);
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_leave_type') {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
|
||||||
|
|
||||||
|
$code = strtoupper(trim($_POST['code'] ?? ''));
|
||||||
|
if (empty($code)) throw new Exception("Code du congé manquant.");
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
// 1. On supprime d'abord ce congé chez tous les membres à qui on l'avait attribué
|
||||||
|
$stmtMeta = $pdo->prepare("DELETE FROM pf_person_leave_meta WHERE leave_type = ?");
|
||||||
|
$stmtMeta->execute([$code]);
|
||||||
|
|
||||||
|
// 2. Ensuite, on supprime le congé du catalogue global
|
||||||
|
$stmtCat = $pdo->prepare("DELETE FROM pf_leave_types WHERE code = ?");
|
||||||
|
$stmtCat->execute([$code]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 5. GESTION DES CONGÉS INDIVIDUELS (MEMBRES) ───
|
||||||
|
if ($action === 'get_person_leaves') {
|
||||||
|
$personId = (int)($_GET['person_id'] ?? 0);
|
||||||
|
$stmt = $pdo->prepare("SELECT id, leave_type, allowance, method, anniversary_date FROM pf_person_leave_meta WHERE person_id = ? ORDER BY leave_type ASC");
|
||||||
|
$stmt->execute([$personId]);
|
||||||
|
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'add_person_leave') {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
|
||||||
|
|
||||||
|
$personId = (int)($_POST['person_id'] ?? 0);
|
||||||
|
$type = strtoupper(trim($_POST['leave_type'] ?? ''));
|
||||||
|
$allowance = (float)($_POST['allowance'] ?? 0);
|
||||||
|
$resetMonth = (int)($_POST['reset_month'] ?? 1);
|
||||||
|
|
||||||
|
$method = in_array($_POST['method'] ?? '', ['FIXED', 'ACCUMULATED']) ? $_POST['method'] : 'FIXED';
|
||||||
|
|
||||||
|
if ($personId <= 0 || empty($type)) throw new Exception("Données invalides.");
|
||||||
|
|
||||||
|
// Vérification des doublons
|
||||||
|
$check = $pdo->prepare("SELECT id FROM pf_person_leave_meta WHERE person_id = ? AND leave_type = ?");
|
||||||
|
$check->execute([$personId, $type]);
|
||||||
|
if ($check->rowCount() > 0) throw new Exception("Ce congé est déjà attribué à cette personne.");
|
||||||
|
|
||||||
|
// On construit la date anniversaire avec le mois choisi par l'utilisateur
|
||||||
|
$anniversaryDate = "2000-" . str_pad((string)$resetMonth, 2, "0", STR_PAD_LEFT) . "-01";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_person_leave_meta (person_id, leave_type, allowance, method, anniversary_date) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$personId, $type, $allowance, $method, $anniversaryDate]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_person_leave') {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception("Méthode non autorisée");
|
||||||
|
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_person_leave_meta WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("Action inconnue : " . htmlspecialchars($action));
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paramètre: school_year_start (optionnel)
|
||||||
|
* - année de début d'année scolaire (ex: 2025 pour 09/2025 -> 08/2026)
|
||||||
|
* - défaut: année courante si non fourni
|
||||||
|
*/
|
||||||
|
$schoolYearStart = isset($_GET['school_year_start'])
|
||||||
|
? (int)$_GET['school_year_start']
|
||||||
|
: (int)date('Y');
|
||||||
|
|
||||||
|
if ($schoolYearStart < 2000 || $schoolYearStart > 2100) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Année scolaire invalide.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$yearStart = $schoolYearStart;
|
||||||
|
$yearEnd = $schoolYearStart + 1;
|
||||||
|
|
||||||
|
// On veut toutes les semaines:
|
||||||
|
// - de septembre (month >= 9) de yearStart
|
||||||
|
// - à août (month <= 8) de yearEnd
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
year,
|
||||||
|
week_iso_year,
|
||||||
|
week_iso_number,
|
||||||
|
week_label,
|
||||||
|
month,
|
||||||
|
month_name,
|
||||||
|
week_start_date,
|
||||||
|
mon_date,
|
||||||
|
tue_date,
|
||||||
|
wed_date,
|
||||||
|
thu_date,
|
||||||
|
fri_date
|
||||||
|
FROM pf_calendar_weeks
|
||||||
|
WHERE
|
||||||
|
(year = :year_start AND month >= 9)
|
||||||
|
OR (year = :year_end AND month <= 8)
|
||||||
|
ORDER BY week_start_date ASC
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([
|
||||||
|
':year_start' => $yearStart,
|
||||||
|
':year_end' => $yearEnd,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$weeks = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'school_year' => $schoolYearStart,
|
||||||
|
'weeks' => $weeks,
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
$year = isset($_GET['year']) ? (int)$_GET['year'] : (int)date('Y');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT
|
||||||
|
year,
|
||||||
|
week_iso_year,
|
||||||
|
week_iso_number,
|
||||||
|
week_label,
|
||||||
|
month,
|
||||||
|
month_name,
|
||||||
|
week_start_date,
|
||||||
|
mon_date,
|
||||||
|
tue_date,
|
||||||
|
wed_date,
|
||||||
|
thu_date,
|
||||||
|
fri_date
|
||||||
|
FROM pf_calendar_weeks
|
||||||
|
WHERE year = :year
|
||||||
|
ORDER BY week_start_date ASC
|
||||||
|
");
|
||||||
|
$stmt->execute([':year' => $year]);
|
||||||
|
$weeks = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode(['weeks' => $weeks]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
require __DIR__ . '/../includes/db.php';
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stmt = $pdo->query("SELECT id, event_date AS date, event_type AS type, person_id, duration FROM pf_events ORDER BY event_date");
|
$stmt = $pdo->query("SELECT id, event_date AS date, event_type AS type, person_id, duration FROM pf_events ORDER BY event_date");
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🔥 LE FIX : On interroge la NOUVELLE table des quotas individuels !
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT
|
||||||
|
person_id,
|
||||||
|
leave_type AS type,
|
||||||
|
allowance,
|
||||||
|
method,
|
||||||
|
anniversary_date AS date
|
||||||
|
FROM pf_person_leave_meta
|
||||||
|
");
|
||||||
|
$balances = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'balances' => $balances
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT person_id, leave_type, snapshot_date, remaining_balance
|
||||||
|
FROM pf_leave_snapshots
|
||||||
|
ORDER BY snapshot_date ASC
|
||||||
|
");
|
||||||
|
$snapshots = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode(['snapshots' => $snapshots]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
// api/get-leaves.php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.person_id,
|
||||||
|
p.name AS person_name,
|
||||||
|
l.leave_type,
|
||||||
|
l.leave_date,
|
||||||
|
l.duration
|
||||||
|
FROM pf_leaves l
|
||||||
|
JOIN pf_people p ON p.id = l.person_id
|
||||||
|
ORDER BY l.leave_date, l.person_id
|
||||||
|
");
|
||||||
|
|
||||||
|
$leaves = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'leaves' => $leaves,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
// /modules/family-calendar/includes/api/get-person-leave-meta.php
|
||||||
|
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT person_id, anniversary_date
|
||||||
|
FROM pf_person_leave_meta
|
||||||
|
");
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'meta' => $rows,
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'error' => true,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
// modules/family-calendar/includes/api/manage-event.php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$action = $input['action'] ?? '';
|
||||||
|
|
||||||
|
if (!$action) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'status' => 'error', 'message' => 'Action manquante.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$eventId = (int)($input['event_id'] ?? 0);
|
||||||
|
if ($eventId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'status' => 'error', 'message' => 'ID manquant.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_events WHERE id = ?");
|
||||||
|
$stmt->execute([$eventId]);
|
||||||
|
echo json_encode(['success' => true, 'status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'update') {
|
||||||
|
$eventId = (int)($input['event_id'] ?? 0);
|
||||||
|
$newType = $input['new_type'] ?? '';
|
||||||
|
if ($eventId <= 0 || !$newType) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'status' => 'error', 'message' => 'Données manquantes.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$newType, $eventId]);
|
||||||
|
echo json_encode(['success' => true, 'status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'bulk_delete_day_types_person') {
|
||||||
|
$dates = $input['dates'] ?? [];
|
||||||
|
$types = $input['types'] ?? [];
|
||||||
|
$person_id = $input['person_id'] ?? null;
|
||||||
|
|
||||||
|
$dates = array_filter($dates, function($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); });
|
||||||
|
|
||||||
|
if (empty($dates) || empty($types)) {
|
||||||
|
echo json_encode(['success' => true, 'status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$datePlaceholders = implode(',', array_fill(0, count($dates), '?'));
|
||||||
|
$typePlaceholders = implode(',', array_fill(0, count($types), '?'));
|
||||||
|
|
||||||
|
$sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders) AND event_type IN ($typePlaceholders)";
|
||||||
|
$params = array_merge($dates, $types);
|
||||||
|
|
||||||
|
// 🔥 LE CORRECTIF : On cherche le 0 en base de données
|
||||||
|
if ($person_id === null || $person_id === '' || (int)$person_id === 0) {
|
||||||
|
$sql .= " AND person_id = 0";
|
||||||
|
} else {
|
||||||
|
$sql .= " AND person_id = ?";
|
||||||
|
$params[] = (int)$person_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'bulk_delete_day_types') {
|
||||||
|
$dates = $input['dates'] ?? [];
|
||||||
|
$types = $input['types'] ?? [];
|
||||||
|
$dates = array_filter($dates, function($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); });
|
||||||
|
|
||||||
|
if (empty($dates) || empty($types)) {
|
||||||
|
echo json_encode(['success' => true, 'status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$datePlaceholders = implode(',', array_fill(0, count($dates), '?'));
|
||||||
|
$typePlaceholders = implode(',', array_fill(0, count($types), '?'));
|
||||||
|
|
||||||
|
$sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders) AND event_type IN ($typePlaceholders)";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute(array_merge($dates, $types));
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'bulk_delete_all') {
|
||||||
|
$dates = $input['dates'] ?? [];
|
||||||
|
$dates = array_filter($dates, function($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); });
|
||||||
|
|
||||||
|
if (empty($dates)) {
|
||||||
|
echo json_encode(['success' => true, 'status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$datePlaceholders = implode(',', array_fill(0, count($dates), '?'));
|
||||||
|
$sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders)";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($dates);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'status' => 'error', 'message' => 'Action non reconnue : ' . $action]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'status' => 'error', 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
// api/manage-leaf.php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($input)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'JSON invalide.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $input['action'] ?? null;
|
||||||
|
if (!$action) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Action manquante.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ===================== DELETE TOUS LES LEAVES D'UN JOUR (EXISTANT) =====================
|
||||||
|
if ($action === 'delete_day') {
|
||||||
|
|
||||||
|
if (empty($input['date'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Date manquante pour delete_day.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateObj = date_create($input['date']);
|
||||||
|
if (!$dateObj) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Date invalide.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$leaveDate = $dateObj->format('Y-m-d');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_leaves WHERE leave_date = ?");
|
||||||
|
$stmt->execute([$leaveDate]);
|
||||||
|
|
||||||
|
echo json_encode(['status' => 'success', 'message' => 'Congés supprimés pour ce jour.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== DELETE POUR UNE PERSONNE / JOUR (SINGLE) =====================
|
||||||
|
if ($action === 'delete_day_person') {
|
||||||
|
$date = $input['date'] ?? null;
|
||||||
|
$personId = $input['person_id'] ?? null;
|
||||||
|
|
||||||
|
if (empty($date) || empty($personId)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'date ou person_id manquant pour delete_day_person.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateObj = date_create($date);
|
||||||
|
if (!$dateObj) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Date invalide pour delete_day_person.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$leaveDate = $dateObj->format('Y-m-d');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
DELETE FROM pf_leaves
|
||||||
|
WHERE leave_date = :date
|
||||||
|
AND person_id = :person_id
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
':date' => $leaveDate,
|
||||||
|
':person_id' => $personId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Congés supprimés pour cette personne et ce jour.',
|
||||||
|
'deleted' => $stmt->rowCount(),
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== BULK DELETE POUR UNE PERSONNE / PLUSIEURS JOURS =====================
|
||||||
|
if ($action === 'bulk_delete_day_person') {
|
||||||
|
$dates = $input['dates'] ?? null;
|
||||||
|
$personId = $input['person_id'] ?? null;
|
||||||
|
|
||||||
|
if (!is_array($dates) || empty($dates) || empty($personId)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'dates ou person_id manquant pour bulk_delete_day_person.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normaliser les dates en 'Y-m-d'
|
||||||
|
$normalizedDates = [];
|
||||||
|
foreach ($dates as $d) {
|
||||||
|
$dateObj = date_create($d);
|
||||||
|
if ($dateObj) {
|
||||||
|
$normalizedDates[] = $dateObj->format('Y-m-d');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($normalizedDates)) {
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Aucune date valide fournie.',
|
||||||
|
'deleted' => 0,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire le IN (...)
|
||||||
|
$placeholders = implode(',', array_fill(0, count($normalizedDates), '?'));
|
||||||
|
$sql = "
|
||||||
|
DELETE FROM pf_leaves
|
||||||
|
WHERE person_id = ?
|
||||||
|
AND leave_date IN ($placeholders)
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
$params = array_merge([$personId], $normalizedDates);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Congés supprimés pour cette personne sur les dates fournies.',
|
||||||
|
'deleted' => $stmt->rowCount(),
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== ACTION NON RECONNUE =====================
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Action non valide.']);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
// modules/family-calendar/includes/api/save-events.php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rawInput = file_get_contents('php://input');
|
||||||
|
$eventsToSave = json_decode($rawInput, true);
|
||||||
|
|
||||||
|
if (empty($eventsToSave) || !is_array($eventsToSave)) {
|
||||||
|
throw new Exception("Aucune donnée d'événement reçue.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
$sql = "INSERT INTO pf_events (event_date, event_type, person_id, duration)
|
||||||
|
VALUES (:event_date, :event_type, :person_id, :duration)";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
$inserted = [];
|
||||||
|
|
||||||
|
foreach ($eventsToSave as $event) {
|
||||||
|
if (empty($event['date']) || empty($event['type'])) {
|
||||||
|
continue; // Sécurité anti-crash
|
||||||
|
}
|
||||||
|
|
||||||
|
$person_id = 0;
|
||||||
|
if (!empty($event['person_id']) && is_numeric($event['person_id'])) {
|
||||||
|
$person_id = (int)$event['person_id'];
|
||||||
|
} elseif (!empty($event['person']) && is_numeric($event['person'])) {
|
||||||
|
$person_id = (int)$event['person'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 LE FIX : On tronque à 50 caractères pour éviter l'erreur "Data truncated"
|
||||||
|
$safeType = substr(trim($event['type']), 0, 50);
|
||||||
|
$duration = isset($event['duration']) ? (float)$event['duration'] : 1.0;
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':event_date' => $event['date'],
|
||||||
|
':event_type' => $safeType,
|
||||||
|
':person_id' => $person_id,
|
||||||
|
':duration' => $duration,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inserted[] = [
|
||||||
|
'id' => $pdo->lastInsertId(),
|
||||||
|
'date' => $event['date'],
|
||||||
|
'type' => $safeType,
|
||||||
|
'duration' => $duration,
|
||||||
|
'person_id' => $person_id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'status' => 'success',
|
||||||
|
'inserted' => $inserted,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Throwable $e) { // 🔥 LE FIX : \Throwable attrape AUSSI les fatals errors PDO
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Erreur Serveur/SQL : ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$input || empty($input['person_id']) || empty($input['leave_type']) || empty($input['snapshot_date']) || !isset($input['remaining_balance'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Données manquantes.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Étape 1 : Par sécurité, on supprime un éventuel snapshot existant exactement à la même date pour cette personne et ce type
|
||||||
|
$stmtDel = $pdo->prepare("DELETE FROM pf_leave_snapshots WHERE person_id = ? AND leave_type = ? AND snapshot_date = ?");
|
||||||
|
$stmtDel->execute([$input['person_id'], $input['leave_type'], $input['snapshot_date']]);
|
||||||
|
|
||||||
|
// Étape 2 : On insère le nouveau solde
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance) VALUES (?, ?, ?, ?)");
|
||||||
|
$stmt->execute([
|
||||||
|
$input['person_id'],
|
||||||
|
$input['leave_type'],
|
||||||
|
$input['snapshot_date'],
|
||||||
|
$input['remaining_balance']
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
// api/save-leaves.php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($input) || empty($input)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Données invalides (attendu tableau JSON).']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exemple d'élément attendu :
|
||||||
|
// { date: '2025-09-01', person_id: 2, leave_type: 'CP', duration: 1.0 }
|
||||||
|
|
||||||
|
$validLeaveTypes = ['CP', 'JRA', 'JA'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
$stmtInsert = $pdo->prepare("
|
||||||
|
INSERT INTO pf_leaves (person_id, leave_type, leave_date, duration)
|
||||||
|
VALUES (:person_id, :leave_type, :leave_date, :duration)
|
||||||
|
");
|
||||||
|
|
||||||
|
foreach ($input as $item) {
|
||||||
|
$date = $item['date'] ?? null;
|
||||||
|
$personId = $item['person_id'] ?? null;
|
||||||
|
$leaveType = $item['leave_type'] ?? null;
|
||||||
|
$duration = isset($item['duration']) ? (float)$item['duration'] : 1.0;
|
||||||
|
|
||||||
|
if (!$date || !$personId || !$leaveType) {
|
||||||
|
continue; // on ignore les lignes incomplètes
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($leaveType, $validLeaveTypes, true)) {
|
||||||
|
continue; // leave_type invalide
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalisation de la date
|
||||||
|
$dateObj = date_create($date);
|
||||||
|
if (!$dateObj) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$leaveDate = $dateObj->format('Y-m-d');
|
||||||
|
|
||||||
|
$stmtInsert->execute([
|
||||||
|
':person_id' => (int)$personId,
|
||||||
|
':leave_type' => $leaveType,
|
||||||
|
':leave_date' => $leaveDate,
|
||||||
|
':duration' => $duration,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Congés enregistrés.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
<?php
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||||
|
|
||||||
|
set_exception_handler(function(\Throwable $e) {
|
||||||
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
|
||||||
|
$UPLOAD_DIR = '/uploads/garage/';
|
||||||
|
if (!is_dir($UPLOAD_DIR)) { @mkdir($UPLOAD_DIR, 0755, true); }
|
||||||
|
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_garage_documents (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vehicle_id INT NOT NULL,
|
||||||
|
maintenance_id INT DEFAULT NULL,
|
||||||
|
label VARCHAR(255) DEFAULT NULL,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) DEFAULT NULL,
|
||||||
|
mime VARCHAR(120) DEFAULT NULL,
|
||||||
|
size INT DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_gd_vehicle (vehicle_id),
|
||||||
|
KEY idx_gd_maint (maintenance_id),
|
||||||
|
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
");
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
function gOk($d) {
|
||||||
|
$flags = JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE;
|
||||||
|
$json = json_encode(['ok' => true, 'data' => $d], $flags);
|
||||||
|
if ($json === false) { http_response_code(500); echo json_encode(['ok' => false, 'error' => 'Erreur encodage JSON : ' . json_last_error_msg()]); exit; }
|
||||||
|
echo $json; exit;
|
||||||
|
}
|
||||||
|
function gErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
|
||||||
|
function gBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
|
||||||
|
|
||||||
|
function handleUpload(string $field, string $dir): ?string {
|
||||||
|
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== 0) return null;
|
||||||
|
$ext = strtolower(pathinfo($_FILES[$field]['name'], PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, ['jpg','jpeg','png','gif','webp'])) return null;
|
||||||
|
$fname = uniqid('g', true) . '.' . $ext;
|
||||||
|
if (move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) return $fname;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGarageDocumentUpload(string $field, string $dir): ?array {
|
||||||
|
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($_FILES[$field]['size'] > 12 * 1024 * 1024) {
|
||||||
|
gErr('Fichier trop volumineux (max 12 Mo).', 400);
|
||||||
|
}
|
||||||
|
$ext = strtolower(pathinfo($_FILES[$field]['name'], PATHINFO_EXTENSION));
|
||||||
|
$allowed = ['pdf','jpg','jpeg','png','gif','webp','doc','docx','xls','xlsx','odt','txt'];
|
||||||
|
if (!in_array($ext, $allowed, true)) {
|
||||||
|
gErr('Format non autorisé.', 400);
|
||||||
|
}
|
||||||
|
$fname = 'd_' . uniqid('', true) . '.' . $ext;
|
||||||
|
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$mimeMap = [
|
||||||
|
'pdf' => 'application/pdf', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
|
||||||
|
'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp',
|
||||||
|
'doc' => 'application/msword', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'xls' => 'application/vnd.ms-excel', 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'odt' => 'application/vnd.oasis.opendocument.text', 'txt' => 'text/plain',
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
'filename' => $fname,
|
||||||
|
'original_name' => $_FILES[$field]['name'],
|
||||||
|
'mime' => $mimeMap[$ext] ?? 'application/octet-stream',
|
||||||
|
'size' => (int) $_FILES[$field]['size'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Vehicles ─────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'vehicles') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$id = $_GET['id'] ?? null;
|
||||||
|
if ($id) {
|
||||||
|
$v = $pdo->prepare("SELECT * FROM pf_vehicles WHERE id = ?"); $v->execute([$id]);
|
||||||
|
$vehicle = $v->fetch(); if (!$vehicle) gErr('Véhicule introuvable', 404);
|
||||||
|
$s = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as total FROM pf_maintenances WHERE vehicle_id = ?"); $s->execute([$id]); $vehicle['stats'] = $s->fetch();
|
||||||
|
$p = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(price*quantity),0) as total FROM pf_parts WHERE vehicle_id = ?"); $p->execute([$id]); $vehicle['parts_stats'] = $p->fetch();
|
||||||
|
$dc = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NULL");
|
||||||
|
$dc->execute([$id]);
|
||||||
|
$vehicle['vehicle_docs_count'] = (int) $dc->fetchColumn();
|
||||||
|
$dm = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NOT NULL");
|
||||||
|
$dm->execute([$id]);
|
||||||
|
$vehicle['maint_docs_count'] = (int) $dm->fetchColumn();
|
||||||
|
gOk($vehicle);
|
||||||
|
}
|
||||||
|
$stmt = $pdo->query("SELECT v.*, (SELECT COUNT(*) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as maintenance_count, (SELECT COALESCE(SUM(cost),0) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as total_cost, (SELECT date FROM pf_maintenances m WHERE m.vehicle_id = v.id ORDER BY date DESC LIMIT 1) as last_maintenance FROM pf_vehicles v ORDER BY v.created_at DESC");
|
||||||
|
gOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$photo = handleUpload('photo', $UPLOAD_DIR);
|
||||||
|
$d = $_POST ?: gBody();
|
||||||
|
|
||||||
|
// 🔥 LE FIX EST ICI : Fonctions boucliers pour MySQL Strict Mode
|
||||||
|
$nullInt = fn($v) => ($v === '' || $v === null) ? null : (int)$v;
|
||||||
|
$nullFloat = fn($v) => ($v === '' || $v === null) ? null : (float)$v;
|
||||||
|
$nullStr = fn($v) => ($v === '' || $v === null) ? null : $v;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_vehicles (name,brand,model,year,license_plate,vin,fuel_type,consumption,color,purchase_date,purchase_price,current_km,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
|
$stmt->execute([
|
||||||
|
$d['name'] ?? '',
|
||||||
|
$d['brand'] ?? '',
|
||||||
|
$d['model'] ?? '',
|
||||||
|
$nullInt($d['year'] ?? null),
|
||||||
|
$nullStr($d['license_plate'] ?? null),
|
||||||
|
$nullStr($d['vin'] ?? null),
|
||||||
|
$d['fuel_type'] ?? 'Essence',
|
||||||
|
$nullFloat($d['consumption'] ?? null),
|
||||||
|
$nullStr($d['color'] ?? null),
|
||||||
|
$nullStr($d['purchase_date'] ?? null),
|
||||||
|
$nullFloat($d['purchase_price'] ?? null),
|
||||||
|
$nullInt($d['current_km'] ?? null) ?? 0,
|
||||||
|
$photo,
|
||||||
|
$nullStr($d['notes'] ?? null)
|
||||||
|
]);
|
||||||
|
gOk(['id' => $pdo->lastInsertId()]);
|
||||||
|
}
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
|
||||||
|
$int_fields = ['year','current_km'];
|
||||||
|
$float_fields = ['purchase_price', 'consumption'];
|
||||||
|
$fields = ['name','brand','model','year','license_plate','vin','fuel_type','consumption','color','purchase_date','purchase_price','current_km','notes'];
|
||||||
|
$sets = array_map(fn($f) => "$f = ?", $fields);
|
||||||
|
$vals = array_map(function($f) use ($d, $int_fields, $float_fields) {
|
||||||
|
$v = $d[$f] ?? null;
|
||||||
|
if ($v === '' || $v === null) return null;
|
||||||
|
if (in_array($f, $int_fields)) return (int)$v;
|
||||||
|
if (in_array($f, $float_fields)) return (float)$v;
|
||||||
|
return $v;
|
||||||
|
}, $fields);
|
||||||
|
$vals[] = $id;
|
||||||
|
$pdo->prepare("UPDATE pf_vehicles SET " . implode(', ', $sets) . ", updated_at = NOW() WHERE id = ?")->execute($vals);
|
||||||
|
gOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
|
||||||
|
$docs = $pdo->prepare('SELECT filename FROM pf_garage_documents WHERE vehicle_id = ?');
|
||||||
|
$docs->execute([$id]);
|
||||||
|
foreach ($docs->fetchAll(PDO::FETCH_COLUMN) as $fn) {
|
||||||
|
if ($fn) {
|
||||||
|
@unlink($UPLOAD_DIR . basename($fn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$v = $pdo->prepare("SELECT photo FROM pf_vehicles WHERE id=?"); $v->execute([$id]); $row = $v->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
|
||||||
|
$pdo->prepare("DELETE FROM pf_vehicles WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Documents (véhicule / entretien — factures, PDF, etc.) ───────────────────
|
||||||
|
if ($action === 'garage_documents') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$vid = $_GET['vehicle_id'] ?? null;
|
||||||
|
$mid = $_GET['maintenance_id'] ?? null;
|
||||||
|
$vehicleOnly = ($_GET['vehicle_only'] ?? '') === '1';
|
||||||
|
if ($mid) {
|
||||||
|
$stmt = $pdo->prepare("SELECT d.*, v.name as vehicle_name FROM pf_garage_documents d JOIN pf_vehicles v ON v.id = d.vehicle_id WHERE d.maintenance_id = ? ORDER BY d.created_at DESC");
|
||||||
|
$stmt->execute([$mid]);
|
||||||
|
gOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
if (!$vid) {
|
||||||
|
gErr('vehicle_id ou maintenance_id requis');
|
||||||
|
}
|
||||||
|
if ($vehicleOnly) {
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NULL ORDER BY created_at DESC");
|
||||||
|
$stmt->execute([$vid]);
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM pf_garage_documents WHERE vehicle_id = ? ORDER BY created_at DESC");
|
||||||
|
$stmt->execute([$vid]);
|
||||||
|
}
|
||||||
|
gOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$d = $_POST;
|
||||||
|
$vid = (int) ($d['vehicle_id'] ?? 0);
|
||||||
|
if ($vid < 1) {
|
||||||
|
gErr('vehicle_id manquant');
|
||||||
|
}
|
||||||
|
$mid = isset($d['maintenance_id']) && $d['maintenance_id'] !== '' ? (int) $d['maintenance_id'] : null;
|
||||||
|
if ($mid) {
|
||||||
|
$chk = $pdo->prepare("SELECT id FROM pf_maintenances WHERE id = ? AND vehicle_id = ?");
|
||||||
|
$chk->execute([$mid, $vid]);
|
||||||
|
if (!$chk->fetch()) {
|
||||||
|
gErr('Entretien invalide pour ce véhicule');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$up = handleGarageDocumentUpload('file', $UPLOAD_DIR);
|
||||||
|
if (!$up) {
|
||||||
|
gErr('Upload échoué ou fichier manquant');
|
||||||
|
}
|
||||||
|
$label = trim($d['label'] ?? '');
|
||||||
|
$pdo->prepare("INSERT INTO pf_garage_documents (vehicle_id, maintenance_id, label, filename, original_name, mime, size) VALUES (?,?,?,?,?,?,?)")
|
||||||
|
->execute([$vid, $mid, $label ?: null, $up['filename'], $up['original_name'], $up['mime'], $up['size']]);
|
||||||
|
gOk(['id' => (int) $pdo->lastInsertId()]);
|
||||||
|
}
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id < 1) {
|
||||||
|
gErr('ID manquant');
|
||||||
|
}
|
||||||
|
$r = $pdo->prepare("SELECT filename FROM pf_garage_documents WHERE id = ?");
|
||||||
|
$r->execute([$id]);
|
||||||
|
$row = $r->fetch();
|
||||||
|
if (!$row) {
|
||||||
|
gErr('Document introuvable', 404);
|
||||||
|
}
|
||||||
|
if (!empty($row['filename'])) {
|
||||||
|
@unlink($UPLOAD_DIR . $row['filename']);
|
||||||
|
}
|
||||||
|
$pdo->prepare("DELETE FROM pf_garage_documents WHERE id = ?")->execute([$id]);
|
||||||
|
gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'garage_document') {
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id < 1) {
|
||||||
|
gErr('ID manquant');
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM pf_garage_documents WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$doc = $stmt->fetch();
|
||||||
|
if (!$doc || empty($doc['filename'])) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$f = basename($doc['filename']);
|
||||||
|
$path = $UPLOAD_DIR . $f;
|
||||||
|
if (!preg_match('/^d_[a-zA-Z0-9._-]+$/', $f) || !is_file($path)) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$mime = $doc['mime'] ?: 'application/octet-stream';
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
$disp = $doc['original_name'] ?: $f;
|
||||||
|
header('Content-Disposition: inline; filename="' . rawurlencode($disp) . '"');
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
readfile($path);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Maintenances ─────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'maintenances') {
|
||||||
|
$vid = $_GET['vehicle_id'] ?? null;
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$id = $_GET['id'] ?? null;
|
||||||
|
if ($id) {
|
||||||
|
$stmt = $pdo->prepare("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.id = ?");
|
||||||
|
$stmt->execute([$id]); $m = $stmt->fetch(); if (!$m) gErr('Entretien introuvable', 404);
|
||||||
|
$dc = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE maintenance_id = ?");
|
||||||
|
$dc->execute([$id]);
|
||||||
|
$m['documents_count'] = (int) $dc->fetchColumn();
|
||||||
|
gOk($m);
|
||||||
|
}
|
||||||
|
if ($vid) {
|
||||||
|
$stmt = $pdo->prepare("SELECT m.*, GROUP_CONCAT(p.name SEPARATOR ', ') as parts_names, COUNT(p.id) as parts_count, COALESCE(SUM(p.price*p.quantity),0) as parts_cost, (SELECT COUNT(*) FROM pf_garage_documents d WHERE d.maintenance_id = m.id) as documents_count FROM pf_maintenances m LEFT JOIN pf_parts p ON p.maintenance_id = m.id WHERE m.vehicle_id = ? GROUP BY m.id ORDER BY m.date DESC, m.created_at DESC");
|
||||||
|
$stmt->execute([$vid]); gOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
$stmt = $pdo->query("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.next_date IS NOT NULL OR m.next_km IS NOT NULL ORDER BY m.next_date ASC");
|
||||||
|
gOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$photo = handleUpload('invoice_photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
|
||||||
|
if (!($d['vehicle_id'] ?? null)) gErr('vehicle_id manquant');
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_maintenances (vehicle_id,type,description,date,km,cost,mechanic,garage_name,next_km,next_date,invoice_photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
|
$nullInt = fn($v) => ($v === '' || $v === null) ? null : (int)$v;
|
||||||
|
$nullFloat = fn($v) => ($v === '' || $v === null) ? null : (float)$v;
|
||||||
|
$nullStr = fn($v) => ($v === '' || $v === null) ? null : $v;
|
||||||
|
$stmt->execute([
|
||||||
|
$d['vehicle_id'],
|
||||||
|
$d['type'] ?? '',
|
||||||
|
$nullStr($d['description'] ?? null),
|
||||||
|
$d['date'] ?? date('Y-m-d'),
|
||||||
|
$nullInt($d['km'] ?? null),
|
||||||
|
$nullFloat($d['cost'] ?? null),
|
||||||
|
$nullStr($d['mechanic'] ?? null),
|
||||||
|
$nullStr($d['garage_name'] ?? null),
|
||||||
|
$nullInt($d['next_km'] ?? null),
|
||||||
|
$nullStr($d['next_date'] ?? null),
|
||||||
|
$photo,
|
||||||
|
$nullStr($d['notes'] ?? null),
|
||||||
|
]);
|
||||||
|
$mid = $pdo->lastInsertId();
|
||||||
|
if (!empty($d['km'])) { $pdo->prepare("UPDATE pf_vehicles SET current_km = GREATEST(current_km, ?), updated_at = NOW() WHERE id = ?")->execute([$d['km'], $d['vehicle_id']]); }
|
||||||
|
gOk(['id' => $mid]);
|
||||||
|
}
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
|
||||||
|
if (empty($d['type'])) gErr('Le type est requis');
|
||||||
|
if (empty($d['date'])) gErr('La date est requise');
|
||||||
|
$int_f = ['km','next_km']; $float_f = ['cost'];
|
||||||
|
$fields = ['type','description','date','km','cost','mechanic','garage_name','next_km','next_date','notes'];
|
||||||
|
$sets = array_map(fn($f) => "$f = ?", $fields);
|
||||||
|
$vals = array_map(function($f) use ($d, $int_f, $float_f) {
|
||||||
|
$v = $d[$f] ?? null;
|
||||||
|
if ($v === '' || $v === null) return null;
|
||||||
|
if (in_array($f, $int_f)) return (int)$v;
|
||||||
|
if (in_array($f, $float_f)) return (float)$v;
|
||||||
|
return $v;
|
||||||
|
}, $fields);
|
||||||
|
$vals[] = $id;
|
||||||
|
$pdo->prepare("UPDATE pf_maintenances SET " . implode(', ', $sets) . " WHERE id = ?")->execute($vals); gOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
|
||||||
|
$docs = $pdo->prepare('SELECT filename FROM pf_garage_documents WHERE maintenance_id = ?');
|
||||||
|
$docs->execute([$id]);
|
||||||
|
foreach ($docs->fetchAll(PDO::FETCH_COLUMN) as $fn) {
|
||||||
|
if ($fn) {
|
||||||
|
@unlink($UPLOAD_DIR . basename($fn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo->prepare("DELETE FROM pf_maintenances WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Parts ────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'parts') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$vid = $_GET['vehicle_id'] ?? null; $mid = $_GET['maintenance_id'] ?? null;
|
||||||
|
$id = $_GET['id'] ?? null;
|
||||||
|
if ($id) { $stmt = $pdo->prepare("SELECT * FROM pf_parts WHERE id = ?"); $stmt->execute([$id]); $p = $stmt->fetch(); if (!$p) gErr('Pièce introuvable', 404); gOk($p); }
|
||||||
|
if ($mid) { $stmt = $pdo->prepare("SELECT * FROM pf_parts WHERE maintenance_id = ? ORDER BY created_at DESC"); $stmt->execute([$mid]); gOk($stmt->fetchAll()); }
|
||||||
|
if ($vid) { $stmt = $pdo->prepare("SELECT p.*, m.type as maintenance_type, m.date as maintenance_date FROM pf_parts p LEFT JOIN pf_maintenances m ON m.id = p.maintenance_id WHERE p.vehicle_id = ? ORDER BY p.created_at DESC"); $stmt->execute([$vid]); gOk($stmt->fetchAll()); }
|
||||||
|
$stmt = $pdo->query("SELECT p.*, v.name as vehicle_name, m.type as maintenance_type, m.date as maintenance_date FROM pf_parts p LEFT JOIN pf_vehicles v ON v.id = p.vehicle_id LEFT JOIN pf_maintenances m ON m.id = p.maintenance_id ORDER BY p.created_at DESC");
|
||||||
|
gOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$photo = handleUpload('photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
|
||||||
|
$nullInt = fn($v) => ($v === '' || $v === null) ? null : (int)$v;
|
||||||
|
$nullFloat = fn($v) => ($v === '' || $v === null) ? null : (float)$v;
|
||||||
|
$nullStr = fn($v) => ($v === '' || $v === null) ? null : $v;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_parts (vehicle_id,maintenance_id,brand,reference,name,category,price,quantity,unit,supplier,purchase_date,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
|
$stmt->execute([
|
||||||
|
$nullInt($d['vehicle_id'] ?? null),
|
||||||
|
$nullInt($d['maintenance_id'] ?? null),
|
||||||
|
$nullStr($d['brand'] ?? null),
|
||||||
|
$nullStr($d['reference'] ?? null),
|
||||||
|
$d['name'] ?? '',
|
||||||
|
$d['category'] ?? 'Autre',
|
||||||
|
$nullFloat($d['price'] ?? null) ?? 0,
|
||||||
|
$nullInt($d['quantity'] ?? null) ?? 1,
|
||||||
|
$d['unit'] ?? 'pièce',
|
||||||
|
$nullStr($d['supplier'] ?? null),
|
||||||
|
$nullStr($d['purchase_date'] ?? null),
|
||||||
|
$photo,
|
||||||
|
$nullStr($d['notes'] ?? null)
|
||||||
|
]);
|
||||||
|
gOk(['id' => $pdo->lastInsertId()]);
|
||||||
|
}
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
|
||||||
|
$int_f = ['quantity']; $float_f = ['price'];
|
||||||
|
$fields = ['brand','reference','name','category','price','quantity','unit','supplier','purchase_date','notes'];
|
||||||
|
$sets = array_map(fn($f) => "$f = ?", $fields);
|
||||||
|
$vals = array_map(function($f) use ($d, $int_f, $float_f) {
|
||||||
|
$v = $d[$f] ?? null;
|
||||||
|
if ($v === '' || $v === null) return null;
|
||||||
|
if (in_array($f, $int_f)) return (int)$v;
|
||||||
|
if (in_array($f, $float_f)) return (float)$v;
|
||||||
|
return $v;
|
||||||
|
}, $fields);
|
||||||
|
$vals[] = $id;
|
||||||
|
$pdo->prepare("UPDATE pf_parts SET " . implode(', ', $sets) . " WHERE id = ?")->execute($vals); gOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
|
||||||
|
$r = $pdo->prepare("SELECT photo FROM pf_parts WHERE id=?"); $r->execute([$id]); $row = $r->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
|
||||||
|
$pdo->prepare("DELETE FROM pf_parts WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stats ────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'stats') {
|
||||||
|
gOk([
|
||||||
|
'vehicles' => $pdo->query("SELECT COUNT(*) FROM pf_vehicles")->fetchColumn(),
|
||||||
|
'maintenances' => $pdo->query("SELECT COUNT(*) FROM pf_maintenances")->fetchColumn(),
|
||||||
|
'parts' => $pdo->query("SELECT COUNT(*) FROM pf_parts")->fetchColumn(),
|
||||||
|
'total_cost' => $pdo->query("SELECT COALESCE(SUM(cost),0) FROM pf_maintenances")->fetchColumn(),
|
||||||
|
'total_parts_cost' => $pdo->query("SELECT COALESCE(SUM(price*quantity),0) FROM pf_parts")->fetchColumn(),
|
||||||
|
'upcoming_reminders'=> $pdo->query("SELECT COUNT(*) FROM pf_maintenances WHERE next_date >= CURDATE() OR next_km IS NOT NULL")->fetchColumn(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Photo upload ──────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'upload_vehicle_photo') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
|
||||||
|
$photo = handleUpload('photo', $UPLOAD_DIR); if (!$photo) gErr('Upload échoué');
|
||||||
|
$old = $pdo->prepare("SELECT photo FROM pf_vehicles WHERE id=?"); $old->execute([$id]); $row = $old->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
|
||||||
|
$pdo->prepare("UPDATE pf_vehicles SET photo = ?, updated_at = NOW() WHERE id = ?")->execute([$photo, $id]); gOk(['photo' => $photo]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'photo') {
|
||||||
|
$f = basename($_GET['file'] ?? '');
|
||||||
|
if ($f && preg_match('/^[a-zA-Z0-9._-]+$/', $f) && file_exists($UPLOAD_DIR . $f)) {
|
||||||
|
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
|
||||||
|
$mime = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif','webp'=>'image/webp'][$ext] ?? 'image/jpeg';
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
readfile($UPLOAD_DIR . $f);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
http_response_code(404); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'upload_part_photo') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
|
||||||
|
$photo = handleUpload('photo', $UPLOAD_DIR); if (!$photo) gErr('Upload échoué');
|
||||||
|
$pdo->prepare("UPDATE pf_parts SET photo = ? WHERE id = ?")->execute([$photo, $id]); gOk(['photo' => $photo]);
|
||||||
|
}
|
||||||
|
gErr('Action inconnue', 404);
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
/* Garage module — light theme matching HouseHub global.css */
|
||||||
|
|
||||||
|
/* Aliases pour les inline styles JS qui utilisent var(--muted) / var(--fg) */
|
||||||
|
:root {
|
||||||
|
--muted: #64748b;
|
||||||
|
--fg: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── SOUS-NAVIGATION ──────────────────────────────────────────────────────── */
|
||||||
|
.garage-subnav {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.garage-subnav .nav-link {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.garage-subnav .nav-link:hover { background: #f1f5f9; color: var(--text-main); }
|
||||||
|
.garage-subnav .nav-link.active { background: #eff6ff; color: var(--primary); border-color: #bfdbfe; }
|
||||||
|
|
||||||
|
/* ─── CONTAINER & PAGES ────────────────────────────────────────────────────── */
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||||
|
.page { display: none; }
|
||||||
|
.page.active { display: block; }
|
||||||
|
|
||||||
|
/* ─── CARD ──────────────────────────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: .75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
.card-title { font-size: 1rem; font-weight: 600; color: var(--text-main); }
|
||||||
|
|
||||||
|
/* ─── STATS GRID ────────────────────────────────────────────────────────────── */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.stat-pill {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-left: 4px solid var(--border-light);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.stat-pill .label { font-size: 0.78rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: .35rem; }
|
||||||
|
.stat-pill .value { font-size: 1.75rem; font-weight: 700; line-height: 1; color: var(--text-main); }
|
||||||
|
.stat-pill.blue { border-left-color: var(--primary); }
|
||||||
|
.stat-pill.blue .value { color: var(--primary); }
|
||||||
|
.stat-pill.green { border-left-color: var(--success); }
|
||||||
|
.stat-pill.green .value { color: var(--success); }
|
||||||
|
.stat-pill.amber { border-left-color: var(--warning); }
|
||||||
|
.stat-pill.amber .value { color: var(--warning); }
|
||||||
|
.stat-pill.red { border-left-color: var(--danger); }
|
||||||
|
.stat-pill.red .value { color: var(--danger); }
|
||||||
|
|
||||||
|
/* ─── VEHICLES GRID ─────────────────────────────────────────────────────────── */
|
||||||
|
.vehicles-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.vehicle-card {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .2s, box-shadow .2s, transform .15s;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.vehicle-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.vehicle-card-img {
|
||||||
|
width: 100%; height: 180px; overflow: hidden; background: #f1f5f9;
|
||||||
|
position: relative; display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.vehicle-card-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.no-photo { font-size: 3.5rem; color: #cbd5e1; }
|
||||||
|
.vehicle-card-body { padding: 1rem; }
|
||||||
|
.vehicle-card-title { font-size: 1rem; font-weight: 700; color: var(--text-main); margin-bottom: .25rem; }
|
||||||
|
.vehicle-card-sub { font-size: .82rem; color: var(--text-muted); margin-bottom: .6rem; }
|
||||||
|
.vehicle-card-stats { display: flex; gap: .75rem; flex-wrap: wrap; }
|
||||||
|
.vc-stat { font-size: .82rem; color: var(--text-muted); }
|
||||||
|
.vehicle-card-footer {
|
||||||
|
padding: .6rem 1rem;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-size: .8rem; color: var(--text-muted);
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── FUEL BADGE ────────────────────────────────────────────────────────────── */
|
||||||
|
.fuel-badge {
|
||||||
|
position: absolute; top: .5rem; right: .5rem;
|
||||||
|
font-size: .7rem; font-weight: 700;
|
||||||
|
padding: .18rem .5rem; border-radius: 6px;
|
||||||
|
text-transform: uppercase; letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
.fuel-Essence { background: rgba(59,130,246,.15); color: #2563eb; }
|
||||||
|
.fuel-Diesel { background: rgba(139,92,246,.15); color: #7c3aed; }
|
||||||
|
.fuel-Hybride { background: rgba(16,185,129,.15); color: #059669; }
|
||||||
|
.fuel-Electrique { background: rgba(6,182,212,.15); color: #0891b2; }
|
||||||
|
.fuel-GPL { background: rgba(245,158,11,.15); color: #d97706; }
|
||||||
|
|
||||||
|
/* ─── PLATE ──────────────────────────────────────────────────────────────────── */
|
||||||
|
.plate {
|
||||||
|
display: inline-block; background: #1d4ed8; color: #fff;
|
||||||
|
font-family: 'Courier New', monospace; font-weight: 700;
|
||||||
|
font-size: .78rem; padding: .18rem .55rem;
|
||||||
|
border-radius: 5px; letter-spacing: .1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── TABLE ──────────────────────────────────────────────────────────────────── */
|
||||||
|
.table-wrap { overflow-x: auto; border-radius: 8px; border: 1px solid var(--border-light); }
|
||||||
|
.table-wrap table { width: 100%; border-collapse: collapse; font-size: .875rem; }
|
||||||
|
.table-wrap table th {
|
||||||
|
background: #f8fafc; color: var(--text-muted);
|
||||||
|
font-weight: 600; text-align: left;
|
||||||
|
padding: .65rem .9rem; border-bottom: 1px solid var(--border-light); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-wrap table td {
|
||||||
|
padding: .65rem .9rem; border-bottom: 1px solid var(--border-light);
|
||||||
|
vertical-align: middle; color: var(--text-main);
|
||||||
|
}
|
||||||
|
.table-wrap table tr:last-child td { border-bottom: none; }
|
||||||
|
.table-wrap table tr:hover td { background: #fafbff; }
|
||||||
|
|
||||||
|
/* ─── BADGES ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-block; font-size: .72rem; font-weight: 600;
|
||||||
|
padding: .22rem .55rem; border-radius: 999px;
|
||||||
|
}
|
||||||
|
.badge-green { background: #dcfce7; color: #15803d; }
|
||||||
|
.badge-blue { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.badge-amber { background: #fef3c7; color: #b45309; }
|
||||||
|
.badge-red { background: #fee2e2; color: #dc2626; }
|
||||||
|
.badge-gray { background: #f1f5f9; color: var(--text-muted); }
|
||||||
|
.badge-purple { background: #ede9fe; color: #6d28d9; }
|
||||||
|
|
||||||
|
/* ─── BUTTONS ────────────────────────────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: .4rem;
|
||||||
|
padding: .5rem 1rem; border-radius: 8px;
|
||||||
|
font-size: .875rem; font-weight: 500; cursor: pointer;
|
||||||
|
border: 1px solid transparent; transition: all .15s;
|
||||||
|
white-space: nowrap; text-decoration: none; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
|
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||||
|
.btn-secondary { background: var(--bg-panel); color: var(--text-main); border-color: var(--border-light); }
|
||||||
|
.btn-secondary:hover { background: #f1f5f9; }
|
||||||
|
.btn-danger { background: #fff; color: var(--danger); border-color: #fecaca; }
|
||||||
|
.btn-danger:hover { background: #fef2f2; }
|
||||||
|
.btn-sm { padding: .3rem .65rem; font-size: .8rem; }
|
||||||
|
.btn-icon { padding: .3rem; min-width: 30px; justify-content: center; }
|
||||||
|
|
||||||
|
/* ─── FORMS ──────────────────────────────────────────────────────────────────── */
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: .35rem; }
|
||||||
|
.form-label { font-size: .82rem; font-weight: 600; color: var(--text-muted); }
|
||||||
|
.form-control {
|
||||||
|
background: #fff; border: 1px solid var(--border-light); color: var(--text-main);
|
||||||
|
border-radius: 8px; padding: .5rem .75rem; font-size: .875rem;
|
||||||
|
width: 100%; outline: none; transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
|
||||||
|
.form-control::placeholder { color: #cbd5e1; }
|
||||||
|
select.form-control option { background: #fff; }
|
||||||
|
textarea.form-control { resize: vertical; min-height: 80px; }
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem; }
|
||||||
|
.form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: .9rem; }
|
||||||
|
|
||||||
|
/* ─── MODALS ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.modal-backdrop {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 200;
|
||||||
|
background: rgba(15,23,42,.5); backdrop-filter: blur(4px);
|
||||||
|
align-items: center; justify-content: center; padding: 1rem;
|
||||||
|
}
|
||||||
|
.modal-backdrop.show { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
width: 100%; max-width: 640px;
|
||||||
|
max-height: 90vh; display: flex; flex-direction: column;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: modalIn .18s ease;
|
||||||
|
}
|
||||||
|
@keyframes modalIn { from { opacity: 0; transform: scale(.96) translateY(-8px); } to { opacity: 1; transform: none; } }
|
||||||
|
.modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-light); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.modal-title { font-size: 1rem; font-weight: 700; color: var(--text-main); }
|
||||||
|
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.25rem; line-height: 1; padding: .2rem; border-radius: 6px; }
|
||||||
|
.modal-close:hover { background: #f1f5f9; color: var(--text-main); }
|
||||||
|
.modal-body { padding: 1.25rem; overflow-y: auto; display: flex; flex-direction: column; gap: .9rem; }
|
||||||
|
.modal-footer { padding: 1rem 1.25rem; border-top: 1px solid var(--border-light); display: flex; justify-content: flex-end; gap: .5rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ─── VEHICLE DETAIL HEADER ─────────────────────────────────────────────────── */
|
||||||
|
.vehicle-detail-header {
|
||||||
|
display: flex; gap: 1.5rem; align-items: flex-start;
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.vehicle-detail-img {
|
||||||
|
width: 180px; height: 130px; border-radius: 8px; flex-shrink: 0;
|
||||||
|
background: #f1f5f9; overflow: hidden; display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.vehicle-detail-img img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.vehicle-meta h2 { font-size: 1.4rem; font-weight: 800; color: var(--text-main); margin-bottom: .25rem; }
|
||||||
|
.vehicle-meta .sub { font-size: .9rem; color: var(--text-muted); margin-bottom: .75rem; }
|
||||||
|
.meta-grid { display: flex; gap: 1.25rem; flex-wrap: wrap; margin-top: .5rem; }
|
||||||
|
.meta-item .k { font-size: .72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.meta-item .v { font-size: .95rem; font-weight: 600; color: var(--text-main); }
|
||||||
|
|
||||||
|
/* ─── TABS ───────────────────────────────────────────────────────────────────── */
|
||||||
|
.tabs { display: flex; gap: .25rem; margin-bottom: 1rem; border-bottom: 1px solid var(--border-light); }
|
||||||
|
.tab-btn {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--text-muted); font-size: .9rem; font-weight: 500;
|
||||||
|
padding: .6rem 1rem; border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px; transition: color .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: var(--text-main); }
|
||||||
|
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||||
|
.tab-pane { display: none; }
|
||||||
|
.tab-pane.active { display: block; }
|
||||||
|
|
||||||
|
/* ─── PARTS ──────────────────────────────────────────────────────────────────── */
|
||||||
|
.part-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border-light); }
|
||||||
|
|
||||||
|
/* ─── EMPTY STATE ────────────────────────────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: .75rem; padding: 3rem 1rem; color: var(--text-muted); text-align: center;
|
||||||
|
}
|
||||||
|
.empty-state .icon { font-size: 2.5rem; opacity: .4; }
|
||||||
|
.empty-state p { font-size: .9rem; }
|
||||||
|
|
||||||
|
/* ─── TOASTS ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999;
|
||||||
|
display: flex; flex-direction: column; align-items: flex-end;
|
||||||
|
gap: .5rem; pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px; padding: .75rem 1rem;
|
||||||
|
font-size: .875rem; min-width: 220px; max-width: 360px; color: var(--text-main);
|
||||||
|
box-shadow: var(--shadow-lg); animation: toastIn .2s ease; pointer-events: auto;
|
||||||
|
border-left: 4px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.toast.success { border-left-color: var(--success); }
|
||||||
|
.toast.error { border-left-color: var(--danger); }
|
||||||
|
.toast.info { border-left-color: var(--primary); }
|
||||||
|
.toast.warning { border-left-color: var(--warning); }
|
||||||
|
@keyframes toastIn { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
/* ─── SCROLL LIST (pièces connues) ──────────────────────────────────────────── */
|
||||||
|
.scroll-list {
|
||||||
|
max-height: 160px; overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-light); border-radius: 8px;
|
||||||
|
background: #fff; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.scroll-list-item {
|
||||||
|
display: flex; align-items: center; gap: .75rem;
|
||||||
|
padding: .45rem .75rem; cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border-light); transition: background .12s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.scroll-list-item:last-child { border-bottom: none; }
|
||||||
|
.scroll-list-item:hover { background: #f8fafc; }
|
||||||
|
.scroll-list-item input[type="radio"] { flex-shrink: 0; }
|
||||||
|
.item-name { flex: 1; font-size: .875rem; font-weight: 500; color: var(--text-main); }
|
||||||
|
.item-ref { font-size: .72rem; font-family: monospace; color: var(--text-muted); }
|
||||||
|
.item-brand { font-size: .75rem; color: var(--text-muted); }
|
||||||
|
.item-price { font-size: .875rem; font-weight: 600; color: var(--primary); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ─── DASHBOARD LAYOUT ─────────────────────────────────────────────────────── */
|
||||||
|
.garage-dash-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr minmax(260px, 380px);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.garage-dash-main { min-width: 0; }
|
||||||
|
.garage-dash-aside { padding: 1rem 1.25rem; }
|
||||||
|
.garage-card-header { flex-wrap: wrap; align-items: center; gap: 0.75rem; }
|
||||||
|
|
||||||
|
.garage-tab-actions {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── TABS (scroll mobile) ─────────────────────────────────────────────────── */
|
||||||
|
.garage-tabs {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
.garage-tabs .tab-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── VEHICLE HEADER ACTIONS ───────────────────────────────────────────────── */
|
||||||
|
.vehicle-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.garage-docs-recap {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.garage-docs-recap strong { color: var(--text-main); }
|
||||||
|
.btn-link-sm {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.btn-link-sm:hover { color: var(--primary-dark); }
|
||||||
|
|
||||||
|
.garage-maint-doc-badge { font-size: 0.65rem; vertical-align: middle; margin-left: 0.15rem; }
|
||||||
|
|
||||||
|
/* ─── DOCUMENTS ───────────────────────────────────────────────────────────── */
|
||||||
|
.garage-docs-card .card-header { padding: 1rem 1.25rem; }
|
||||||
|
.garage-doc-upload-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.garage-doc-upload-btn { align-self: flex-end; flex-shrink: 0; }
|
||||||
|
.garage-doc-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0.35rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
.garage-doc-empty { padding: 1.25rem !important; }
|
||||||
|
.garage-doc-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.garage-doc-item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.garage-doc-item:last-child { border-bottom: none; }
|
||||||
|
.garage-doc-link {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.garage-doc-link:hover { text-decoration: underline; }
|
||||||
|
.garage-doc-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.garage-doc-item .btn-icon { flex-shrink: 0; margin-left: auto; }
|
||||||
|
|
||||||
|
/* ─── RESPONSIVE ─────────────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container { padding: 1rem; }
|
||||||
|
.vehicle-detail-header { flex-direction: column; }
|
||||||
|
.vehicle-detail-img { width: 100%; height: 200px; }
|
||||||
|
.vehicle-detail-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
.vehicle-detail-actions .btn { width: 100%; justify-content: center; }
|
||||||
|
.form-row, .form-row-3 { grid-template-columns: 1fr; }
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.modal { max-height: 95vh; }
|
||||||
|
.vehicles-grid { grid-template-columns: 1fr; }
|
||||||
|
.meta-grid { gap: 0.75rem; }
|
||||||
|
.garage-subnav { padding: 8px 12px; overflow-x: auto; flex-wrap: nowrap; }
|
||||||
|
.garage-subnav .nav-link { white-space: nowrap; }
|
||||||
|
.garage-dash-layout { grid-template-columns: 1fr; }
|
||||||
|
.garage-dash-aside { order: -1; }
|
||||||
|
.garage-doc-upload-bar { flex-direction: column; align-items: stretch; }
|
||||||
|
.garage-doc-upload-btn { width: 100%; align-self: stretch; }
|
||||||
|
.garage-doc-item { flex-direction: column; align-items: stretch; }
|
||||||
|
.garage-doc-item .btn-icon { margin-left: 0; align-self: flex-end; }
|
||||||
|
.garage-doc-meta { white-space: normal; }
|
||||||
|
.toast-container {
|
||||||
|
left: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: max(1rem, env(safe-area-inset-bottom, 0px));
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.toast { max-width: none; min-width: 0; width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.table-wrap {
|
||||||
|
margin-left: -0.25rem;
|
||||||
|
margin-right: -0.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.table-wrap table { min-width: 640px; }
|
||||||
|
#inline-part-form .form-row,
|
||||||
|
#inline-part-form .form-row-3 { grid-template-columns: 1fr !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ DARK MODE — Garage module ══════════════════════════════════ */
|
||||||
|
[data-theme="dark"] { --muted: #8b949e; --fg: #e6edf3; }
|
||||||
|
[data-theme="dark"] .garage-subnav { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .garage-subnav .nav-link { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .garage-subnav .nav-link:hover { background: rgba(255,255,255,.06); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .garage-subnav .nav-link.active { background: rgba(59,130,246,.15); color: var(--primary); border-color: rgba(59,130,246,.3); }
|
||||||
|
[data-theme="dark"] .card,
|
||||||
|
[data-theme="dark"] .vehicle-detail-header { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .stat-pill { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .stat-pill .label { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .vehicle-card { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .vehicle-card:hover { border-color: var(--primary); }
|
||||||
|
[data-theme="dark"] .vehicle-card-img { background: #1c2128; }
|
||||||
|
[data-theme="dark"] .vehicle-card-footer { background: #1c2128; border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .vehicle-card-title { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .vehicle-card-sub { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .vehicle-detail-img { background: #1c2128; }
|
||||||
|
[data-theme="dark"] .vehicle-meta h2 { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .meta-item .v { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .card-title, [data-theme="dark"] .modal-title { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .table-wrap { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .table-wrap table th { background: #1c2128; color: var(--text-muted); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .table-wrap table td { border-color: rgba(48,54,61,.4); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .table-wrap table tr:hover td { background: rgba(255,255,255,.03); }
|
||||||
|
[data-theme="dark"] .tabs { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .tab-btn { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .tab-btn:hover { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .form-label { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .form-control { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .form-control::placeholder { color: #484f58; }
|
||||||
|
[data-theme="dark"] select.form-control option { background: #1c2128; }
|
||||||
|
[data-theme="dark"] .modal { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .modal-header,
|
||||||
|
[data-theme="dark"] .modal-footer { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .modal-close:hover { background: rgba(255,255,255,.06); }
|
||||||
|
[data-theme="dark"] .btn-secondary { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .btn-secondary:hover { background: rgba(255,255,255,.06); }
|
||||||
|
[data-theme="dark"] .btn-danger { background: #1c2128; border-color: rgba(239,68,68,.4); }
|
||||||
|
[data-theme="dark"] .btn-danger:hover { background: rgba(239,68,68,.08); }
|
||||||
|
[data-theme="dark"] .badge-gray { background: rgba(139,148,158,.15); color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .toast { background: var(--bg-panel); border-color: var(--border-light); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .part-thumb { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .scroll-list { background: #1c2128; border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .scroll-list-item { border-color: rgba(48,54,61,.5); }
|
||||||
|
[data-theme="dark"] .scroll-list-item:hover { background: rgba(255,255,255,.04); }
|
||||||
|
[data-theme="dark"] .item-name { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] #inline-part-form { background: #1c2128 !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .garage-docs-recap { background: rgba(255,255,255,.04); border-color: var(--border-light); color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .garage-docs-recap strong { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .btn-link-sm { color: var(--primary); }
|
||||||
|
[data-theme="dark"] .garage-doc-list { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .garage-doc-item { background: #1c2128; border-color: rgba(48,54,61,.5); }
|
||||||
|
[data-theme="dark"] .garage-doc-link { color: #93c5fd; }
|
||||||
|
[data-theme="dark"] .garage-doc-meta { color: var(--text-muted); }
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../../includes/auth.php';
|
||||||
|
require __DIR__ . '/../../includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($action === 'get_all') {
|
||||||
|
$occ = $pdo->query("SELECT * FROM pf_gift_occasions ORDER BY sort_order ASC, id ASC")->fetchAll();
|
||||||
|
$proches = $pdo->query("SELECT * FROM pf_people WHERE role IN ('proche_adulte', 'proche_enfant') ORDER BY name ASC")->fetchAll();
|
||||||
|
echo json_encode(['success' => true, 'data' => ['occasions' => $occ, 'proches' => $proches]]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'save_occasion') {
|
||||||
|
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
|
||||||
|
$name = trim($_POST['name']);
|
||||||
|
$icon = trim($_POST['icon']) ?: '🎁';
|
||||||
|
$month_date = trim($_POST['month_date']) ?: null;
|
||||||
|
|
||||||
|
if (empty($name)) throw new Exception("Le nom est obligatoire.");
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_gift_occasions SET name=?, icon=?, month_date=? WHERE id=?");
|
||||||
|
$stmt->execute([$name, $icon, $month_date, $id]);
|
||||||
|
} else {
|
||||||
|
$code = 'CUST_' . strtoupper(substr(preg_replace('/[^A-Za-z0-9]/', '', $name), 0, 4)) . '_' . rand(100, 999);
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_gift_occasions (code, name, icon, month_date) VALUES (?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$code, $name, $icon, $month_date]);
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_occasion') {
|
||||||
|
$id = (int)$_POST['id'];
|
||||||
|
$pdo->prepare("DELETE FROM pf_gift_occasions WHERE id=?")->execute([$id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'toggle_occasion') {
|
||||||
|
$id = (int)$_POST['id'];
|
||||||
|
$state = (int)$_POST['state'];
|
||||||
|
$pdo->prepare("UPDATE pf_gift_occasions SET is_active=? WHERE id=?")->execute([$state, $id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'save_proche') {
|
||||||
|
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
|
||||||
|
$name = trim($_POST['name']);
|
||||||
|
$role = $_POST['role'];
|
||||||
|
|
||||||
|
if (empty($name)) throw new Exception("Le nom est obligatoire.");
|
||||||
|
if (!in_array($role, ['proche_adulte', 'proche_enfant'])) throw new Exception("Rôle invalide.");
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
// Sécurité : on s'assure qu'on modifie bien un proche et pas un parent
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_people SET name=?, role=? WHERE id=? AND role IN ('proche_adulte', 'proche_enfant')");
|
||||||
|
$stmt->execute([$name, $role, $id]);
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_people (name, role, color) VALUES (?, ?, '#94a3b8')");
|
||||||
|
$stmt->execute([$name, $role]);
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_proche') {
|
||||||
|
$id = (int)$_POST['id'];
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_people WHERE id=? AND role IN ('proche_adulte', 'proche_enfant')");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,529 @@
|
|||||||
|
/* modules/gift-list/gift-list.css */
|
||||||
|
|
||||||
|
.pf-gift-list h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.cl-titlebar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
.cl-view-switch {
|
||||||
|
display: flex;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.cl-view-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
.cl-view-btn.is-active {
|
||||||
|
background: var(--text-main);
|
||||||
|
color: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === FILTRES === */
|
||||||
|
.pf-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
position: sticky;
|
||||||
|
top: 64px;
|
||||||
|
z-index: 50;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pf-filter-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SECTIONS ENFANTS === */
|
||||||
|
.pf-child-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.pf-child-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.pf-child-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === BARRE DES TOTAUX (PILLS) === */
|
||||||
|
.pf-child-totals-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.pf-child-totals-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.pf-summary-pill {
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--bg-page);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === FEED DES CARTES CADEAUX === */
|
||||||
|
.pf-gift-feed {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
.pf-gift-card-compact {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
padding: 12px;
|
||||||
|
position: relative;
|
||||||
|
transition: 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
.pf-gift-card-compact:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.pf-gift-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.pf-gift-link {
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.pf-gift-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.pf-gift-price {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.pf-pill-adult {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.pf-gift-payer {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.pf-gift-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === TRICOUNT / LIQUIDATIONS === */
|
||||||
|
.pf-tricount-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.pf-tricount-item {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.cl-debt-matrix th:first-child,
|
||||||
|
.cl-debt-matrix td:first-child {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: var(--bg-page);
|
||||||
|
z-index: 2;
|
||||||
|
border-right: 2px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.gift-tricount-section {
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
.gift-tricount-title {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
.gift-tricount-success {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.gift-tricount-debt-amount {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.gift-matrix-details {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.gift-matrix-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.gift-matrix-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.cl-debt-matrix th.sticky-col.bg-header {
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
.cl-debt-matrix th.sticky-col.bg-body {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.gift-cell-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--bg-danger-soft);
|
||||||
|
}
|
||||||
|
.gift-cell-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === TYPOGRAPHIE & ICONES === */
|
||||||
|
.cl-occasion-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.cl-occasion-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.gift-empty-state {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === COMPOSANT MULTI-SELECT VANILLA === */
|
||||||
|
.pf-multi-select {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.pf-ms-trigger {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.pf-ms-trigger:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.pf-ms-trigger.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
.pf-ms-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 200px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.pf-ms-dropdown.open {
|
||||||
|
display: flex;
|
||||||
|
animation: popDown 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
.pf-ms-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
.pf-ms-option:hover {
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
.pf-ms-option input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
.pf-ms-option.is-all {
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pf-child-section {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.pf-filter-bar {
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
.pf-filter-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.pf-gift-feed {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.pf-gift-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.pf-gift-price {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.pf-gift-feed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === MODALE EDITION CADEAU === */
|
||||||
|
.gift-modal-custom-content {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
.gift-modal-custom-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.gift-modal-custom-title {
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.gift-modal-close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.gift-form-group-spaced {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
.gift-modal-custom-footer {
|
||||||
|
padding-top: 15px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-pane {
|
||||||
|
display: none;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
.gl-pane.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.gl-pane-title {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.gl-pane-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.gl-form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.gl-divider {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.gl-input-sm {
|
||||||
|
flex: 0 0 60px;
|
||||||
|
}
|
||||||
|
.gl-input-md {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-loader {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.align-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
COMPOSANTS MODALE & EDITION IN-PLACE
|
||||||
|
========================================= */
|
||||||
|
|
||||||
|
/* Sélecteur d'émojis horizontal */
|
||||||
|
.emoji-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.emoji-option {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: transparent;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.emoji-option:hover {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.emoji-option.selected {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(
|
||||||
|
--bg-subtle
|
||||||
|
); /* ou un bleu très clair rgba(59, 130, 246, 0.1) */
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alignement des boutons d'ajout */
|
||||||
|
.modal-actions-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Édition In-Place sur les lignes de la liste */
|
||||||
|
.inline-edit-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap; /* Évite que ça dépasse sur mobile */
|
||||||
|
}
|
||||||
|
.inline-edit-form .pf-input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
// modules/gift-list/save-gift.php
|
||||||
|
|
||||||
|
require __DIR__ . '/../../includes/auth.php';
|
||||||
|
require_login('/login.php');
|
||||||
|
require __DIR__ . '/../../includes/db.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
header('Location: /gift-list.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
$tableName = 'pf_gifts'; // Harmonisé avec gift-list.php
|
||||||
|
|
||||||
|
// Récupération des données
|
||||||
|
$action = $_POST['action'] ?? 'create';
|
||||||
|
$gift_id = (int)($_POST['gift_id'] ?? 0);
|
||||||
|
|
||||||
|
// Logique de redirection (pour rester sur la bonne vue)
|
||||||
|
// Par défaut on renvoie vers le referer ou vers la page principale
|
||||||
|
$redirectUrl = '/gift-list.php';
|
||||||
|
$occasionForView = $_POST['occasion'] ?? '';
|
||||||
|
if (in_array($occasionForView, ['ANNIV', 'SANT'])) {
|
||||||
|
$redirectUrl .= '?view=anniversary';
|
||||||
|
} else {
|
||||||
|
$redirectUrl .= '?view=nadal';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- SUPPRESSION ---
|
||||||
|
if ($action === 'delete') {
|
||||||
|
if ($gift_id > 0) {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM {$tableName} WHERE id = :id");
|
||||||
|
$stmt->execute(['id' => $gift_id]);
|
||||||
|
}
|
||||||
|
header("Location: $redirectUrl");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Champs communs
|
||||||
|
$year = (int)($_POST['year'] ?? date('Y'));
|
||||||
|
$adult_name = trim($_POST['adult_name'] ?? '');
|
||||||
|
$payer_name = trim($_POST['payer_name'] ?? '');
|
||||||
|
$child_name = trim($_POST['child_name'] ?? '');
|
||||||
|
$occasion = trim($_POST['occasion'] ?? '');
|
||||||
|
$gift_desc = trim($_POST['gift_description'] ?? '');
|
||||||
|
$prod_link = trim($_POST['product_link'] ?? '');
|
||||||
|
$amount = ($_POST['amount'] !== '') ? (float)$_POST['amount'] : 0.0;
|
||||||
|
|
||||||
|
// Si payeur vide, c'est l'adulte responsable qui paye
|
||||||
|
if ($payer_name === '') {
|
||||||
|
$payer_name = $adult_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation minimale
|
||||||
|
if (!$adult_name || !$child_name || !$occasion || !$gift_desc) {
|
||||||
|
// En cas d'erreur, on redirige sans rien faire (ou on pourrait gérer une erreur)
|
||||||
|
header("Location: $redirectUrl");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UPDATE ---
|
||||||
|
if ($action === 'update' && $gift_id > 0) {
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE {$tableName}
|
||||||
|
SET year = :year,
|
||||||
|
adult_name = :adult_name,
|
||||||
|
payer_name = :payer_name,
|
||||||
|
child_name = :child_name,
|
||||||
|
occasion = :occasion,
|
||||||
|
gift_description = :gift_description,
|
||||||
|
product_link = :product_link,
|
||||||
|
amount = :amount
|
||||||
|
WHERE id = :id
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $gift_id,
|
||||||
|
'year' => $year,
|
||||||
|
'adult_name' => $adult_name,
|
||||||
|
'payer_name' => $payer_name,
|
||||||
|
'child_name' => $child_name,
|
||||||
|
'occasion' => $occasion,
|
||||||
|
'gift_description' => $gift_desc,
|
||||||
|
'product_link' => $prod_link ?: null,
|
||||||
|
'amount' => $amount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// --- CREATE ---
|
||||||
|
else {
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO {$tableName}
|
||||||
|
(year, adult_name, payer_name, child_name, occasion, gift_description, product_link, amount)
|
||||||
|
VALUES
|
||||||
|
(:year, :adult_name, :payer_name, :child_name, :occasion, :gift_description, :product_link, :amount)
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
'year' => $year,
|
||||||
|
'adult_name' => $adult_name,
|
||||||
|
'payer_name' => $payer_name,
|
||||||
|
'child_name' => $child_name,
|
||||||
|
'occasion' => $occasion,
|
||||||
|
'gift_description' => $gift_desc,
|
||||||
|
'product_link' => $prod_link ?: null,
|
||||||
|
'amount' => $amount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Log l'erreur si besoin
|
||||||
|
}
|
||||||
|
|
||||||
|
header("Location: $redirectUrl");
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_exception_handler(function (\Throwable $e) {
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(500);
|
||||||
|
}
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
label VARCHAR(500) NOT NULL,
|
||||||
|
in_cart TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
");
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_grocery_history (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
label_hash CHAR(64) NOT NULL,
|
||||||
|
label_display VARCHAR(500) NOT NULL,
|
||||||
|
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_grocery_hist_hash (label_hash),
|
||||||
|
KEY idx_hist_last (last_used_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
");
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
function gOk($d)
|
||||||
|
{
|
||||||
|
echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
function gErr($m, $c = 400)
|
||||||
|
{
|
||||||
|
http_response_code($c);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $m]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
function gBody()
|
||||||
|
{
|
||||||
|
return json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryNormalize(string $label): string
|
||||||
|
{
|
||||||
|
$t = trim($label);
|
||||||
|
if ($t === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (function_exists('mb_strtolower')) {
|
||||||
|
$t = mb_strtolower($t, 'UTF-8');
|
||||||
|
} else {
|
||||||
|
$t = strtolower($t);
|
||||||
|
}
|
||||||
|
if (function_exists('mb_strlen') && mb_strlen($t, 'UTF-8') > 500) {
|
||||||
|
$t = mb_substr($t, 0, 500, 'UTF-8');
|
||||||
|
} elseif (strlen($t) > 500) {
|
||||||
|
$t = substr($t, 0, 500);
|
||||||
|
}
|
||||||
|
return $t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryHistoryMax(PDO $pdo): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$s = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'");
|
||||||
|
$v = $s ? $s->fetchColumn() : false;
|
||||||
|
$n = ($v !== false && $v !== null && $v !== '') ? (int) $v : 20;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$n = 20;
|
||||||
|
}
|
||||||
|
return max(1, min(50, $n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryTouchHistory(PDO $pdo, string $labelDisplay): void
|
||||||
|
{
|
||||||
|
$norm = groceryNormalize($labelDisplay);
|
||||||
|
if ($norm === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$disp = trim($labelDisplay);
|
||||||
|
if (function_exists('mb_strlen') && mb_strlen($disp, 'UTF-8') > 500) {
|
||||||
|
$disp = mb_substr($disp, 0, 500, 'UTF-8');
|
||||||
|
} elseif (strlen($disp) > 500) {
|
||||||
|
$disp = substr($disp, 0, 500);
|
||||||
|
}
|
||||||
|
$hash = hash('sha256', $norm, false);
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO pf_grocery_history (label_hash, label_display) VALUES (?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()'
|
||||||
|
)->execute([$hash, $disp]);
|
||||||
|
|
||||||
|
$max = groceryHistoryMax($pdo);
|
||||||
|
$cnt = (int) $pdo->query('SELECT COUNT(*) FROM pf_grocery_history')->fetchColumn();
|
||||||
|
if ($cnt <= $max) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$toDel = $cnt - $max;
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM pf_grocery_history ORDER BY last_used_at ASC, id ASC LIMIT ?');
|
||||||
|
$stmt->execute([$toDel]);
|
||||||
|
$ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if (!$ids) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$pdo->prepare("DELETE FROM pf_grocery_history WHERE id IN ($placeholders)")->execute($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryCurrentNormSet(PDO $pdo): array
|
||||||
|
{
|
||||||
|
$rows = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$set = [];
|
||||||
|
foreach ($rows as $lab) {
|
||||||
|
$n = groceryNormalize((string) $lab);
|
||||||
|
if ($n !== '') {
|
||||||
|
$set[$n] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'items') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$rows = $pdo->query(
|
||||||
|
"SELECT id, label, in_cart, position, created_at, updated_at
|
||||||
|
FROM pf_grocery_items
|
||||||
|
ORDER BY in_cart ASC, position ASC, id ASC"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$r['in_cart'] = (int) $r['in_cart'];
|
||||||
|
$r['id'] = (int) $r['id'];
|
||||||
|
$r['position'] = (int) $r['position'];
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
gOk($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$d = gBody();
|
||||||
|
$label = trim($d['label'] ?? '');
|
||||||
|
if ($label === '') {
|
||||||
|
gErr('Libellé requis');
|
||||||
|
}
|
||||||
|
$norm = groceryNormalize($label);
|
||||||
|
$chk = $pdo->query('SELECT label FROM pf_grocery_items');
|
||||||
|
while ($existing = $chk->fetchColumn()) {
|
||||||
|
if (groceryNormalize((string) $existing) === $norm) {
|
||||||
|
gErr(function_exists('tr') ? tr('groceries_err_duplicate') : 'Ce produit est déjà dans la liste.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$max = (int) $pdo->query('SELECT COALESCE(MAX(position),0) FROM pf_grocery_items')->fetchColumn();
|
||||||
|
$pdo->prepare('INSERT INTO pf_grocery_items (label, position) VALUES (?,?)')->execute([$label, $max + 1]);
|
||||||
|
$id = (int) $pdo->lastInsertId();
|
||||||
|
groceryTouchHistory($pdo, $label);
|
||||||
|
$s = $pdo->prepare('SELECT id, label, in_cart, position, created_at, updated_at FROM pf_grocery_items WHERE id=?');
|
||||||
|
$s->execute([$id]);
|
||||||
|
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$row['in_cart'] = (int) $row['in_cart'];
|
||||||
|
$row['id'] = (int) $row['id'];
|
||||||
|
$row['position'] = (int) $row['position'];
|
||||||
|
gOk($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||||
|
if ($id < 1) {
|
||||||
|
gErr('ID manquant');
|
||||||
|
}
|
||||||
|
$d = gBody();
|
||||||
|
if (array_key_exists('in_cart', $d)) {
|
||||||
|
$in = !empty($d['in_cart']) ? 1 : 0;
|
||||||
|
$pdo->prepare('UPDATE pf_grocery_items SET in_cart=?, updated_at=NOW() WHERE id=?')->execute([$in, $id]);
|
||||||
|
gOk(['in_cart' => $in]);
|
||||||
|
}
|
||||||
|
$label = trim($d['label'] ?? '');
|
||||||
|
if ($label === '') {
|
||||||
|
gErr('Libellé requis');
|
||||||
|
}
|
||||||
|
$pdo->prepare('UPDATE pf_grocery_items SET label=?, updated_at=NOW() WHERE id=?')->execute([$label, $id]);
|
||||||
|
groceryTouchHistory($pdo, $label);
|
||||||
|
gOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||||
|
if ($id < 1) {
|
||||||
|
gErr('ID manquant');
|
||||||
|
}
|
||||||
|
$pdo->prepare('DELETE FROM pf_grocery_items WHERE id=?')->execute([$id]);
|
||||||
|
gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'history' && $method === 'GET') {
|
||||||
|
$max = groceryHistoryMax($pdo);
|
||||||
|
$rows = $pdo->query(
|
||||||
|
'SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT 100'
|
||||||
|
)->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$onList = groceryCurrentNormSet($pdo);
|
||||||
|
$labels = [];
|
||||||
|
foreach ($rows as $disp) {
|
||||||
|
$disp = (string) $disp;
|
||||||
|
if (groceryNormalize($disp) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($onList[groceryNormalize($disp)])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$labels[] = $disp;
|
||||||
|
if (count($labels) >= $max) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gOk(['labels' => $labels, 'history_max' => $max]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'uncheck_all' && $method === 'POST') {
|
||||||
|
$pdo->exec('UPDATE pf_grocery_items SET in_cart=0');
|
||||||
|
gOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_picked' && $method === 'POST') {
|
||||||
|
$labs = $pdo->query('SELECT label FROM pf_grocery_items WHERE in_cart=1')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($labs as $lab) {
|
||||||
|
groceryTouchHistory($pdo, (string) $lab);
|
||||||
|
}
|
||||||
|
$pdo->exec('DELETE FROM pf_grocery_items WHERE in_cart=1');
|
||||||
|
gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'clear_all' && $method === 'POST') {
|
||||||
|
$labs = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($labs as $lab) {
|
||||||
|
groceryTouchHistory($pdo, (string) $lab);
|
||||||
|
}
|
||||||
|
$pdo->exec('DELETE FROM pf_grocery_items');
|
||||||
|
gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
gErr('Action inconnue', 404);
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/* HouseHub — Courses (liste de courses) */
|
||||||
|
.groceries-layout {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-hero {
|
||||||
|
padding: 1.25rem 0 1rem;
|
||||||
|
}
|
||||||
|
.groceries-hero h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.groceries-hero p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Encart ajout — plus visible, aligné sur les cartes HouseHub */
|
||||||
|
.groceries-add-card {
|
||||||
|
margin-bottom: 1.35rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.groceries-add-card-inner {
|
||||||
|
padding: 1.15rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-quick-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.groceries-quick-add .groceries-add-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.groceries-add-submit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 14px 22px !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.groceries-quick-add {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.groceries-add-submit {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions secondaires — même famille que pf-btn du reste du site */
|
||||||
|
.groceries-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1.15rem;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn {
|
||||||
|
padding: 10px 18px !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn--danger {
|
||||||
|
color: var(--danger) !important;
|
||||||
|
border-color: rgba(239, 68, 68, 0.35) !important;
|
||||||
|
background: var(--bg-panel) !important;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn--danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.08) !important;
|
||||||
|
border-color: rgba(239, 68, 68, 0.5) !important;
|
||||||
|
color: var(--danger) !important;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn--outline {
|
||||||
|
border: 1px dashed var(--border-strong) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn--outline:hover {
|
||||||
|
background: var(--bg-page) !important;
|
||||||
|
color: var(--text-main) !important;
|
||||||
|
border-color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-section-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.groceries-section-title:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.groceries-row:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.groceries-row.in-cart {
|
||||||
|
background: linear-gradient(90deg, rgba(34, 197, 94, 0.08) 0%, var(--bg-panel) 48%);
|
||||||
|
border-color: rgba(34, 197, 94, 0.28);
|
||||||
|
}
|
||||||
|
.groceries-row.in-cart:hover {
|
||||||
|
border-color: rgba(34, 197, 94, 0.45);
|
||||||
|
}
|
||||||
|
.groceries-row.in-cart .groceries-label {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-check {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
background: var(--bg-page);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: transparent;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s, transform 0.12s;
|
||||||
|
}
|
||||||
|
.groceries-check:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
}
|
||||||
|
.groceries-check:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
/* Produit validé / dans le caddie — vert franc */
|
||||||
|
.groceries-check.checked {
|
||||||
|
background: linear-gradient(145deg, #22c55e 0%, #16a34a 100%);
|
||||||
|
border-color: #15803d;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.28), 0 2px 6px rgba(22, 163, 74, 0.35);
|
||||||
|
}
|
||||||
|
.groceries-check.checked:hover {
|
||||||
|
background: linear-gradient(145deg, #4ade80 0%, #22c55e 100%);
|
||||||
|
border-color: #166534;
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.35), 0 3px 10px rgba(22, 163, 74, 0.4);
|
||||||
|
}
|
||||||
|
.groceries-check.checked::after {
|
||||||
|
content: '✓';
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.15rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.groceries-empty .groceries-empty-icon {
|
||||||
|
font-size: 2.75rem;
|
||||||
|
opacity: 0.35;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.groceries-toast {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-width: 200px;
|
||||||
|
color: var(--text-main);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
}
|
||||||
|
.groceries-toast.error {
|
||||||
|
border-left-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .groceries-add-card {
|
||||||
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-row {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-row:hover {
|
||||||
|
border-color: #484f58;
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-row.in-cart {
|
||||||
|
background: linear-gradient(90deg, rgba(34, 197, 94, 0.12) 0%, var(--bg-panel) 50%);
|
||||||
|
border-color: rgba(34, 197, 94, 0.35);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-check {
|
||||||
|
border-color: #484f58;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-check:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-check.checked {
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.22), 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Historique produits */
|
||||||
|
.groceries-history-card {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
padding: 1.15rem 1.2rem 1.2rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
.groceries-history-h2 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.groceries-history-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.groceries-history-settings-link {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.groceries-history-settings-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.groceries-history-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.groceries-history-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.45rem 0.75rem 0.45rem 0.55rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-main);
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.groceries-history-chip:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
box-shadow: 0 1px 4px rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
.groceries-history-chip-plus {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-on-primary, #fff);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.groceries-history-empty {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .groceries-history-card {
|
||||||
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-history-chip {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-history-chip:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||